mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-20 11:04:19 +01:00
Compare commits
112 Commits
renovate/s
...
fix/auto-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2b9ae7e1 | ||
|
|
56db3591b9 | ||
|
|
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 | ||
|
|
d9677cbece | ||
|
|
90e9084949 | ||
|
|
115c163aeb | ||
|
|
a58a4da4f3 | ||
|
|
c02baf2831 | ||
|
|
3848877021 | ||
|
|
1f54ccc52c | ||
|
|
08efa1b0f7 | ||
|
|
90ea934548 | ||
|
|
1c158dea4e | ||
|
|
9a7b9c9de2 | ||
|
|
ceeacda7f9 | ||
|
|
b8780f34ec | ||
|
|
97ef9b5ee7 | ||
|
|
ff708ec832 | ||
|
|
d6980cfc8e |
@@ -13,7 +13,7 @@ build:
|
||||
|
||||
- run:
|
||||
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/inject_android_credentials
|
||||
|
||||
@@ -15,7 +15,7 @@ build:
|
||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
||||
- run:
|
||||
name: Prebuild (Android TV, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
command: bun x expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
@@ -18,10 +18,10 @@ build:
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
# android/ is gitignored, so generate native code fresh. --no-install
|
||||
# because deps are already installed above; bunx keeps it on bun.
|
||||
# because deps are already installed above; bun x keeps it on bun.
|
||||
- run:
|
||||
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
|
||||
# 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.
|
||||
- run:
|
||||
name: Prebuild (iOS/tvOS, bun)
|
||||
command: bunx expo prebuild --platform ios --no-install
|
||||
command: bun x expo prebuild --platform ios --no-install
|
||||
|
||||
- run:
|
||||
name: Install CocoaPods
|
||||
|
||||
376
.github/workflows/build-apps.yml
vendored
376
.github/workflows/build-apps.yml
vendored
@@ -30,64 +30,66 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- parallel:
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
@@ -130,64 +132,66 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
- parallel:
|
||||
- name: 🗑️ Free Disk Space
|
||||
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
|
||||
with:
|
||||
tool-cache: false
|
||||
mandb: true
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- parallel:
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
|
||||
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
|
||||
# fail). Pin Temurin 17 for a deterministic Android build.
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: 💾 Cache Gradle global
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches/modules-2
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-gradle-
|
||||
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
@@ -229,19 +233,33 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
@@ -259,19 +277,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -301,19 +306,26 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
@@ -331,12 +343,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 0
|
||||
@@ -368,19 +374,33 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
@@ -398,19 +418,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
@@ -438,19 +445,26 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
@@ -468,12 +482,6 @@ jobs:
|
||||
- name: 🛠️ Generate project files
|
||||
run: bun run prebuild:tv
|
||||
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
EXPO_TV: 1
|
||||
|
||||
24
.github/workflows/check-lockfile.yml
vendored
24
.github/workflows/check-lockfile.yml
vendored
@@ -18,19 +18,19 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
submodules: recursive
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
|
||||
15
.github/workflows/detect-duplicate.yml
vendored
15
.github/workflows/detect-duplicate.yml
vendored
@@ -20,14 +20,15 @@ jobs:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.ts
|
||||
|
||||
98
.github/workflows/linting.yml
vendored
98
.github/workflows/linting.yml
vendored
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,39 +11,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate_pr_title:
|
||||
name: "📝 Validate PR Title"
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
id: lint_pr_title
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey there and thank you for opening this pull request! 👋🏼
|
||||
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||
|
||||
**Error details:**
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
delete: true
|
||||
|
||||
dependency-review:
|
||||
name: 🔍 Vulnerable Dependencies
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -67,18 +39,26 @@ jobs:
|
||||
name: 🚑 Expo Doctor Check
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -103,24 +83,26 @@ jobs:
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
- parallel:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
# renovate: datasource=node-version depName=node versioning=node
|
||||
node-version: "24.18.0"
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
81
.github/workflows/pr-title-comment.yml
vendored
Normal file
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
|
||||
39
.github/workflows/release.yml
vendored
39
.github/workflows/release.yml
vendored
@@ -13,6 +13,8 @@ name: 🚀 Release (EAS build + submit)
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
# Queue successive releases in order instead of dropping the extra pending run.
|
||||
queue: max
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -63,18 +65,26 @@ jobs:
|
||||
artifact_name: streamyfin-android-tv-apk
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
@@ -89,13 +99,6 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
|
||||
# tvOS uses credentialsSource: local — restore the gitignored
|
||||
# credentials.json + cert + provisioning profiles from secrets.
|
||||
- name: 🔐 Restore tvOS signing credentials
|
||||
|
||||
25
.github/workflows/update-issue-form.yml
vendored
25
.github/workflows/update-issue-form.yml
vendored
@@ -25,19 +25,20 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# On `release` events GITHUB_SHA is the tagged commit — without this the
|
||||
# script would regenerate the form from the tag's (stale) copy and the bot
|
||||
# PR would revert any form edits made on develop since that release.
|
||||
ref: develop
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 🔢 Populate version dropdown from GitHub releases
|
||||
id: populate
|
||||
|
||||
2
app.json
2
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
|
||||
@@ -73,7 +73,6 @@ export default function IndexLayout() {
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
@@ -158,7 +157,6 @@ export default function IndexLayout() {
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
|
||||
@@ -324,32 +324,44 @@ export default function SettingsTV() {
|
||||
// MPV alignment options
|
||||
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",
|
||||
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(
|
||||
() => [
|
||||
{ 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",
|
||||
selected: currentAlignY === "center",
|
||||
},
|
||||
{
|
||||
label: "Bottom",
|
||||
label: t("home.settings.subtitles.align.bottom"),
|
||||
value: "bottom",
|
||||
selected: currentAlignY === "bottom",
|
||||
},
|
||||
],
|
||||
[currentAlignY],
|
||||
[currentAlignY, t],
|
||||
);
|
||||
|
||||
// Cache mode options
|
||||
@@ -499,13 +511,13 @@ export default function SettingsTV() {
|
||||
|
||||
const alignXLabel = useMemo(() => {
|
||||
const option = alignXOptions.find((o) => o.selected);
|
||||
return option?.label || "Center";
|
||||
}, [alignXOptions]);
|
||||
return option?.label || t("home.settings.subtitles.align.center");
|
||||
}, [alignXOptions, t]);
|
||||
|
||||
const alignYLabel = useMemo(() => {
|
||||
const option = alignYOptions.find((o) => o.selected);
|
||||
return option?.label || "Bottom";
|
||||
}, [alignYOptions]);
|
||||
return option?.label || t("home.settings.subtitles.align.bottom");
|
||||
}, [alignYOptions, t]);
|
||||
|
||||
const typographyScaleLabel = useMemo(() => {
|
||||
const option = typographyScaleOptions.find((o) => o.selected);
|
||||
@@ -645,7 +657,7 @@ export default function SettingsTV() {
|
||||
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||
/>
|
||||
<TVSettingsStepper
|
||||
label='Vertical Margin'
|
||||
label={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
onDecrease={() => {
|
||||
const newValue = Math.max(
|
||||
@@ -663,11 +675,11 @@ export default function SettingsTV() {
|
||||
}}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Horizontal Alignment'
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
value={alignXLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Horizontal Alignment",
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_x"),
|
||||
options: alignXOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
@@ -677,11 +689,11 @@ export default function SettingsTV() {
|
||||
}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label='Vertical Alignment'
|
||||
label={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
value={alignYLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: "Vertical Alignment",
|
||||
title: t("home.settings.subtitles.mpv_subtitle_align_y"),
|
||||
options: alignYOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({
|
||||
@@ -854,6 +866,13 @@ export default function SettingsTV() {
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.use_episode_images_next_up")}
|
||||
value={settings.useEpisodeImagesForNextUp}
|
||||
onToggle={(value) =>
|
||||
updateSettings({ useEpisodeImagesForNextUp: value })
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_home_backdrop")}
|
||||
value={settings.showHomeBackdrop}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
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 { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
@@ -50,12 +51,12 @@ export default function AppearanceHideLibrariesPage() {
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||
className='px-4'
|
||||
className='px-4 pt-4'
|
||||
>
|
||||
<ListGroup title={t("home.settings.other.hide_libraries")}>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
@@ -71,7 +72,7 @@ export default function AppearanceHideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<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>
|
||||
</DisabledSetting>
|
||||
</ScrollView>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
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 { Loader } from "@/components/Loader";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
@@ -46,7 +47,7 @@ export default function HideLibrariesPage() {
|
||||
<ListGroup>
|
||||
{data?.map((view) => (
|
||||
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
@@ -60,7 +61,7 @@ export default function HideLibrariesPage() {
|
||||
))}
|
||||
</ListGroup>
|
||||
<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>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||
import { useNavigation } from "expo-router";
|
||||
import type * as SharingType from "expo-sharing";
|
||||
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 Collapsible from "react-native-collapsible";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { Loader } from "@/components/Loader";
|
||||
@@ -72,6 +75,25 @@ export default function Page() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
@@ -88,8 +110,15 @@ export default function Page() {
|
||||
}, [share, loading]);
|
||||
|
||||
return (
|
||||
<View className='flex-1'>
|
||||
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||
<ScrollView
|
||||
// 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
|
||||
id={orderFilterId}
|
||||
queryKey='log'
|
||||
@@ -98,7 +127,6 @@ export default function Page() {
|
||||
values={[order]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||
disableSearch={true}
|
||||
/>
|
||||
<FilterButton
|
||||
id={levelsFilterId}
|
||||
@@ -108,71 +136,80 @@ export default function Page() {
|
||||
values={levels}
|
||||
title={t("home.settings.logs.level")}
|
||||
renderItemLabel={(level) => level}
|
||||
disableSearch={true}
|
||||
multiple={true}
|
||||
/>
|
||||
</View>
|
||||
<ScrollView
|
||||
className='pb-4 px-4'
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom }}
|
||||
>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{filteredLogs?.map((log, index) => (
|
||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||
<TouchableOpacity
|
||||
disabled={!log.data}
|
||||
onPress={() =>
|
||||
setState((v) => ({
|
||||
...v,
|
||||
[log.timestamp]: !v[log.timestamp],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<Text
|
||||
className={`mb-1
|
||||
<View className='flex flex-col space-y-2 px-4'>
|
||||
{filteredLogs?.map((log, index) => (
|
||||
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||
<TouchableOpacity
|
||||
disabled={!log.data}
|
||||
onPress={() =>
|
||||
setState((v) => ({
|
||||
...v,
|
||||
[log.timestamp]: !v[log.timestamp],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<Text
|
||||
className={`mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
${log.level === "DEBUG" && "text-purple-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
|
||||
<Text className='text-xs'>
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text selectable className='text-xs'>
|
||||
{log.message}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{log.data && (
|
||||
<>
|
||||
{!state[log.timestamp] && (
|
||||
<Text className='text-xs mt-0.5'>
|
||||
{t("home.settings.logs.click_for_more_info")}
|
||||
</Text>
|
||||
)}
|
||||
<Collapsible collapsed={!state[log.timestamp]}>
|
||||
<View className='mt-2 flex flex-col space-y-2'>
|
||||
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Collapsible>
|
||||
</>
|
||||
<Text className='text-xs'>
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-xs'>{log.message}</Text>
|
||||
{/* Keep the whole collapsed row tappable: the hint lives inside
|
||||
the toggle so tapping it expands too. */}
|
||||
{log.data && !state[log.timestamp] && (
|
||||
<Text className='text-xs mt-0.5'>
|
||||
{t("home.settings.logs.click_for_more_info")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{filteredLogs?.length === 0 && (
|
||||
<Text className='opacity-50'>
|
||||
{t("home.settings.logs.no_logs_available")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{log.data && (
|
||||
<Collapsible collapsed={!state[log.timestamp]}>
|
||||
<View className='mt-2 flex flex-col space-y-2'>
|
||||
<ScrollView
|
||||
className='rounded-xl'
|
||||
style={codeBlockStyle}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{/* 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 { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
@@ -136,7 +136,7 @@ export default function MusicSettingsPage() {
|
||||
title={t("home.settings.music.prefer_downloaded")}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.preferLocalAudio}
|
||||
disabled={pluginSettings?.preferLocalAudio?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -159,7 +159,7 @@ export default function MusicSettingsPage() {
|
||||
title={t("home.settings.music.lookahead_enabled")}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.audioLookaheadEnabled}
|
||||
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -233,7 +233,7 @@ export default function MusicSettingsPage() {
|
||||
})}
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<ListGroup className='mt-4'>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteDownloadedSongsClicked}
|
||||
|
||||
@@ -17,13 +17,14 @@ export default function PlaybackControlsPage() {
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className='p-4 flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<View className='mb-4'>
|
||||
<View>
|
||||
<MediaProvider>
|
||||
<MediaToggles 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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function JellyseerrPluginPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<View className='p-4'>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</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 DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function KefinTweaksPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
className='p-4'
|
||||
>
|
||||
<View className='px-4'>
|
||||
<KefinTweaksSettings />
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
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) => {
|
||||
updateSettings({
|
||||
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
|
||||
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||
if (!marlinUrlLocked) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<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;
|
||||
|
||||
@@ -72,52 +67,42 @@ export default function MarlinSearchPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<View className='px-4 pt-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={
|
||||
pluginSettings?.searchEngine?.locked === true ||
|
||||
!!pluginSettings?.streamyStatsServerUrl?.value
|
||||
}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
|
||||
subtitle (same pattern as the Streamystats settings) — no clipping. */}
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
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
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
<SettingSwitch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={searchEngineLocked || hasStreamystats}
|
||||
onValueChange={(val) => {
|
||||
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
value={settings.searchEngine === "Marlin"}
|
||||
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({
|
||||
searchEngine: value ? "Marlin" : "Jellyfin",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</DisabledSetting>
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View
|
||||
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
|
||||
<ListGroup className='mt-2'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.marlin_search.url")}
|
||||
disabledByAdmin={marlinUrlLocked}
|
||||
>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className='text-white'
|
||||
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
|
||||
className='text-white text-right flex-1'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
</View>
|
||||
</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 { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function PluginsPage() {
|
||||
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 (
|
||||
<ScrollView
|
||||
@@ -18,6 +37,17 @@ export default function PluginsPage() {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
<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>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Linking,
|
||||
ScrollView,
|
||||
Switch,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
} = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
// Local state for all editable fields
|
||||
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
|
||||
);
|
||||
|
||||
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 cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
|
||||
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -134,7 +132,7 @@ export default function StreamystatsPage() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className='px-4'>
|
||||
<View className='px-4 pt-4'>
|
||||
<ListGroup className='flex-1'>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.url")}
|
||||
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
|
||||
placeholder={t(
|
||||
"home.settings.plugins.streamystats.server_url_placeholder",
|
||||
)}
|
||||
value={url}
|
||||
value={effectiveUrl}
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.enable_search")}
|
||||
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
|
||||
disabledByAdmin={searchLocked}
|
||||
>
|
||||
<Switch
|
||||
value={useForSearch}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
{/* Locked controls show the live admin value and can't be toggled —
|
||||
local form state would let the switch flip while the write guard
|
||||
drops the change. */}
|
||||
<SettingSwitch
|
||||
value={
|
||||
searchLocked
|
||||
? settings?.searchEngine === "Streamystats"
|
||||
: useForSearch
|
||||
}
|
||||
disabled={!isStreamystatsEnabled || searchLocked}
|
||||
onValueChange={setUseForSearch}
|
||||
/>
|
||||
</ListItem>
|
||||
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_movie_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
|
||||
}
|
||||
disabledByAdmin={movieRecsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={movieRecs}
|
||||
<SettingSwitch
|
||||
value={
|
||||
movieRecsLocked
|
||||
? (settings?.streamyStatsMovieRecommendations ?? false)
|
||||
: movieRecs
|
||||
}
|
||||
onValueChange={setMovieRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || movieRecsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_series_recommendations",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
|
||||
}
|
||||
disabledByAdmin={seriesRecsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={seriesRecs}
|
||||
<SettingSwitch
|
||||
value={
|
||||
seriesRecsLocked
|
||||
? (settings?.streamyStatsSeriesRecommendations ?? false)
|
||||
: seriesRecs
|
||||
}
|
||||
onValueChange={setSeriesRecs}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || seriesRecsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.enable_promoted_watchlists",
|
||||
)}
|
||||
disabledByAdmin={
|
||||
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
|
||||
}
|
||||
disabledByAdmin={promotedWatchlistsLocked}
|
||||
>
|
||||
<Switch
|
||||
value={promotedWatchlists}
|
||||
<SettingSwitch
|
||||
value={
|
||||
promotedWatchlistsLocked
|
||||
? (settings?.streamyStatsPromotedWatchlists ?? false)
|
||||
: promotedWatchlists
|
||||
}
|
||||
onValueChange={setPromotedWatchlists}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
|
||||
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
|
||||
disabledByAdmin={hideWatchlistsTabLocked}
|
||||
>
|
||||
<Switch
|
||||
value={hideWatchlistsTab}
|
||||
<SettingSwitch
|
||||
value={
|
||||
hideWatchlistsTabLocked
|
||||
? (settings?.hideWatchlistsTab ?? false)
|
||||
: hideWatchlistsTab
|
||||
}
|
||||
onValueChange={setHideWatchlistsTab}
|
||||
disabled={!isStreamystatsEnabled}
|
||||
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
|
||||
{t("home.settings.plugins.streamystats.home_sections_hint")}
|
||||
</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 */}
|
||||
{!isUrlLocked && isStreamystatsEnabled && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -349,9 +349,6 @@ const page: React.FC = () => {
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -376,7 +373,6 @@ const page: React.FC = () => {
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -401,9 +397,6 @@ const page: React.FC = () => {
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -421,9 +414,6 @@ const page: React.FC = () => {
|
||||
renderItemLabel={(item) =>
|
||||
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) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -568,9 +568,6 @@ const Page = () => {
|
||||
values={selectedGenres}
|
||||
title={t("library.filters.genres")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -595,7 +592,6 @@ const Page = () => {
|
||||
values={selectedYears}
|
||||
title={t("library.filters.years")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) => item.includes(search)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -620,9 +616,6 @@ const Page = () => {
|
||||
values={selectedTags}
|
||||
title={t("library.filters.tags")}
|
||||
renderItemLabel={(item) => item.toString()}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -640,9 +633,6 @@ const Page = () => {
|
||||
renderItemLabel={(item) =>
|
||||
sortOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -660,9 +650,6 @@ const Page = () => {
|
||||
renderItemLabel={(item) =>
|
||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -680,9 +667,6 @@ const Page = () => {
|
||||
renderItemLabel={(item) =>
|
||||
generalFilters.find((i) => i.key === item)?.value || ""
|
||||
}
|
||||
searchFilter={(item, search) =>
|
||||
item.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ArtistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
{t("music.missing_library_id")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PlaylistsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
{t("music.missing_library_id")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function SuggestionsScreen() {
|
||||
return (
|
||||
<View className='flex-1 justify-center items-center bg-black px-6'>
|
||||
<Text className='text-neutral-500 text-center'>
|
||||
Missing music library id.
|
||||
{t("music.missing_library_id")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
@@ -72,6 +73,7 @@ const ARTWORK_SIZE = SCREEN_WIDTH - 80;
|
||||
type ViewMode = "player" | "queue";
|
||||
|
||||
export default function NowPlayingScreen() {
|
||||
const { t } = useTranslation();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
@@ -230,7 +232,9 @@ export default function NowPlayingScreen() {
|
||||
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>
|
||||
</BottomSheetModalProvider>
|
||||
);
|
||||
@@ -267,7 +271,7 @@ export default function NowPlayingScreen() {
|
||||
: "text-neutral-500"
|
||||
}
|
||||
>
|
||||
Now Playing
|
||||
{t("music.now_playing")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -718,6 +722,7 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
onRemoveFromQueue,
|
||||
onReorderQueue,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const renderQueueItem = useCallback(
|
||||
({ item, drag, isActive, getIndex }: RenderItemParams<BaseItemDto>) => {
|
||||
const index = getIndex() ?? 0;
|
||||
@@ -831,13 +836,15 @@ const QueueView: React.FC<QueueViewProps> = ({
|
||||
ListHeaderComponent={
|
||||
<View className='px-4 py-2'>
|
||||
<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>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import {
|
||||
getMediaInfoApi,
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} 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 { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
|
||||
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
|
||||
import {
|
||||
LOCAL_SUBTITLE_INDEX_START,
|
||||
toServerSubtitleIndex,
|
||||
} from "@/components/video-player/controls/types";
|
||||
import {
|
||||
PlaybackSpeedScope,
|
||||
updatePlaybackSpeedSettings,
|
||||
@@ -49,15 +54,16 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
getExternalSubtitleUrl,
|
||||
getMpvAudioId,
|
||||
getMpvSubtitleId,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
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
|
||||
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(() => {
|
||||
const fetchStreamData = async (): Promise<Stream | null> => {
|
||||
setStreamStatus({ isLoading: true, isError: false });
|
||||
@@ -445,6 +482,12 @@ export default function DirectPlayerPage() {
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
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]);
|
||||
@@ -619,32 +662,20 @@ export default function DirectPlayerPage() {
|
||||
const mediaSource = stream.mediaSource;
|
||||
const isTranscoding = Boolean(mediaSource?.TranscodingUrl);
|
||||
|
||||
// Get external subtitle URLs
|
||||
// - Online: prepend API base path to server URLs
|
||||
// - Offline: use local file paths (stored in DeliveryUrl during download)
|
||||
let externalSubs: string[] | undefined;
|
||||
if (!offline && api?.basePath) {
|
||||
externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) =>
|
||||
s.Type === "Subtitle" &&
|
||||
s.DeliveryMethod === "External" &&
|
||||
s.DeliveryUrl,
|
||||
).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!);
|
||||
}
|
||||
// Get external subtitle URLs — getExternalSubtitleUrl is the shared source
|
||||
// of truth with identity matching (online: basePath + DeliveryUrl unless
|
||||
// IsExternalUrl; offline: local file path stored in DeliveryUrl).
|
||||
const externalSubs = mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle" && s.DeliveryMethod === "External",
|
||||
)
|
||||
.map((s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
)
|
||||
.filter((u): u is string => !!u);
|
||||
|
||||
// Calculate track IDs for initial selection
|
||||
const initialSubtitleId = getMpvSubtitleId(
|
||||
mediaSource,
|
||||
subtitleIndex,
|
||||
isTranscoding,
|
||||
);
|
||||
// Audio maps positionally (audio tracks aren't reordered or hidden like
|
||||
// subtitles). The subtitle selection is applied later, once MPV's real track
|
||||
// list is known — see applySubtitleSelection / onTracksReady.
|
||||
const initialAudioId = getMpvAudioId(
|
||||
mediaSource,
|
||||
audioIndex,
|
||||
@@ -662,7 +693,6 @@ export default function DirectPlayerPage() {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
cacheConfig: {
|
||||
@@ -710,7 +740,6 @@ export default function DirectPlayerPage() {
|
||||
playbackPositionFromUrl,
|
||||
api?.basePath,
|
||||
api?.accessToken,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
offline,
|
||||
settings.mpvCacheEnabled,
|
||||
@@ -893,6 +922,29 @@ export default function DirectPlayerPage() {
|
||||
// Check if we're transcoding
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
|
||||
// A transcoded stream only carries the audio track the server encoded
|
||||
// into it — switching requires re-negotiating the stream with the new
|
||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
||||
if (isTranscoding) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: String(index),
|
||||
// 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
|
||||
const mpvTrackId = getMpvAudioId(
|
||||
stream?.mediaSource,
|
||||
@@ -904,34 +956,114 @@ export default function DirectPlayerPage() {
|
||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[
|
||||
stream?.mediaSource,
|
||||
item?.Id,
|
||||
currentSubtitleIndex,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
);
|
||||
|
||||
// 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(
|
||||
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 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) {
|
||||
// Disable subtitles
|
||||
await videoRef.current?.disableSubtitles?.();
|
||||
} else {
|
||||
// Convert Jellyfin index to MPV track ID
|
||||
const mpvTrackId = getMpvSubtitleId(
|
||||
stream?.mediaSource,
|
||||
index,
|
||||
isTranscoding,
|
||||
);
|
||||
|
||||
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
|
||||
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
|
||||
}
|
||||
setCurrentSubtitleIndex(index);
|
||||
const result = await applySubtitleSelection(index);
|
||||
// Safety net: a menu-listed sub the player can't select (server-burned
|
||||
// Encode, sidecar never sub-added) needs the server to re-process the
|
||||
// stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replaceWithTrackSelection({ subtitleIndex: String(index) });
|
||||
}
|
||||
},
|
||||
[stream?.mediaSource],
|
||||
[
|
||||
applySubtitleSelection,
|
||||
replaceWithTrackSelection,
|
||||
stream?.mediaSource,
|
||||
currentSubtitleIndex,
|
||||
],
|
||||
);
|
||||
|
||||
// Technical info toggle handler
|
||||
@@ -1050,6 +1182,10 @@ export default function DirectPlayerPage() {
|
||||
previousItem.UserData?.PlaybackPositionTicks?.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);
|
||||
}, [
|
||||
previousItem,
|
||||
@@ -1062,9 +1198,24 @@ export default function DirectPlayerPage() {
|
||||
]);
|
||||
|
||||
// TV: Add subtitle file to player (for client-side downloaded subtitles)
|
||||
const addSubtitleFile = useCallback(async (path: string) => {
|
||||
await videoRef.current?.addSubtitleFile?.(path, true);
|
||||
}, []);
|
||||
const addSubtitleFile = useCallback(
|
||||
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
|
||||
// Re-fetches the media source to pick up newly downloaded subtitles
|
||||
@@ -1290,12 +1441,16 @@ export default function DirectPlayerPage() {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(
|
||||
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);
|
||||
}}
|
||||
onTracksReady={() => {
|
||||
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 && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
@@ -75,6 +76,20 @@ export default function TVOptionModal() {
|
||||
}, [isReady]);
|
||||
|
||||
const handleSelect = (value: any) => {
|
||||
if (modalState?.deferApplyUntilDismissed) {
|
||||
// onSelect navigates (the transcode audio switch replacing the player);
|
||||
// a router.replace fired while this modal is the active route would be
|
||||
// swallowed. Close FIRST, apply after dismissal.
|
||||
const onSelect = modalState.onSelect;
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
InteractionManager.runAfterInteractions(() => onSelect?.(value));
|
||||
return;
|
||||
}
|
||||
// State-only callers (detail page, library filters, settings): run before
|
||||
// closing so the re-render happens while the modal is up. Deferring it until
|
||||
// after dismissal re-renders the page after focus returns and yanks TV
|
||||
// focus, leaving navigation stuck.
|
||||
modalState?.onSelect(value);
|
||||
store.set(tvOptionModalAtom, null);
|
||||
router.back();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -192,6 +193,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -328,7 +330,7 @@ const SubtitleResultCard = React.forwardRef<
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.flagText, { fontSize: scaleSize(10) }]}>
|
||||
Hash Match
|
||||
{t("player.hash_match")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -645,10 +647,23 @@ export default function TVSubtitleModal() {
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(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?.();
|
||||
handleClose();
|
||||
},
|
||||
[handleClose],
|
||||
[handleClose, modalState?.deferApplyUntilDismissed],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native";
|
||||
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<Stack.Screen options={{ title: t("home.oops") }} />
|
||||
<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}>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
<ThemedText type='link'>{t("not_found.go_home")}</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
|
||||
288
app/_layout.tsx
288
app/_layout.tsx
@@ -11,6 +11,7 @@ import { Image } from "expo-image";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
|
||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
@@ -27,6 +28,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
@@ -85,7 +87,8 @@ configureReanimatedLogger({
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -350,9 +353,12 @@ function Layout() {
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(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(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
"Notification received while app running:",
|
||||
notification.request.content.title,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -421,143 +427,149 @@ function Layout() {
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<InactivityProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<WifiSsidProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
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
|
||||
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
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
{!Platform.isTV && (
|
||||
<PendingAccountSaveModal />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</WifiSsidProvider>
|
||||
</InactivityProvider>
|
||||
</JellyfinProvider>
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
20
bun.lock
20
bun.lock
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
"dependencies": {
|
||||
"@bottom-tabs/react-navigation": "1.2.0",
|
||||
"@bottom-tabs/react-navigation": "1.4.0",
|
||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||
"@expo/metro-runtime": "~56.0.15",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
@@ -31,6 +31,7 @@
|
||||
"expo-brightness": "~56.0.5",
|
||||
"expo-build-properties": "~56.0.18",
|
||||
"expo-camera": "~56.0.8",
|
||||
"expo-clipboard": "~56.0.4",
|
||||
"expo-constants": "~56.0.18",
|
||||
"expo-crypto": "~56.0.4",
|
||||
"expo-dev-client": "~56.0.20",
|
||||
@@ -38,6 +39,7 @@
|
||||
"expo-font": "~56.0.6",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.11",
|
||||
"expo-key-event": "^1.9.0",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.14",
|
||||
"expo-localization": "~56.0.6",
|
||||
@@ -62,7 +64,7 @@
|
||||
"react-i18next": "17.0.8",
|
||||
"react-native": "npm:react-native-tvos@0.85.3-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-collapsible": "^1.6.2",
|
||||
"react-native-country-flag": "^2.0.2",
|
||||
@@ -109,7 +111,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"expo-doctor": "1.20.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
@@ -285,7 +287,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1001,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-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-crypto": ["expo-crypto@56.0.4", "", { "peerDependencies": { "expo": "*" } }, "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g=="],
|
||||
@@ -1015,7 +1019,7 @@
|
||||
|
||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||
|
||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||
"expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
|
||||
|
||||
@@ -1031,6 +1035,8 @@
|
||||
|
||||
"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-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=="],
|
||||
@@ -1589,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-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=="],
|
||||
|
||||
@@ -1641,7 +1647,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the
|
||||
// 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`;
|
||||
@@ -61,7 +63,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
}
|
||||
|
||||
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)
|
||||
return <View className='aspect-video border border-neutral-800 w-44' />;
|
||||
|
||||
@@ -40,7 +40,10 @@ import {
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
} 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 useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -56,6 +59,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { formatDuration, runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
@@ -232,12 +236,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
return streams ?? [];
|
||||
}, [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 streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||
(s) => s.Type === "Subtitle",
|
||||
);
|
||||
return streams ?? [];
|
||||
return streams ? [...streams].sort(compareTracksForMenu) : [];
|
||||
}, [selectedOptions?.mediaSource]);
|
||||
|
||||
// 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
|
||||
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)
|
||||
// Also includes locally downloaded subtitles from OpenSubtitles
|
||||
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||
@@ -411,11 +413,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
)
|
||||
: freshItem.MediaSources?.[0];
|
||||
|
||||
// Get subtitle streams from the fresh data
|
||||
const streams =
|
||||
mediaSource?.MediaStreams?.filter(
|
||||
// Get subtitle streams from the fresh data, ordered like jellyfin-web
|
||||
// (embedded first, externals last) — same as the initial list.
|
||||
const streams = [
|
||||
...(mediaSource?.MediaStreams?.filter(
|
||||
(s: MediaStream) => s.Type === "Subtitle",
|
||||
) ?? [];
|
||||
) ?? []),
|
||||
].sort(compareTracksForMenu);
|
||||
|
||||
// Convert to Track[] with setTrack callbacks
|
||||
const tracks: Track[] = streams.map((stream) => ({
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
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 OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
@@ -63,9 +64,12 @@ export const MediaSourceButton: React.FC<Props> = ({
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || [],
|
||||
// Order like jellyfin-web (embedded first, externals last, forced/default up).
|
||||
[
|
||||
...(selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || []),
|
||||
].sort(compareTracksForMenu),
|
||||
[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 { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -209,6 +210,7 @@ const PlatformDropdownComponent = ({
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
@@ -380,7 +382,7 @@ const PlatformDropdownComponent = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -502,8 +502,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
onPress={onPress}
|
||||
className={"relative flex-1"}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -36,6 +37,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
colors,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
@@ -168,8 +170,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
accessibilityLabel={t("accessibility.play_button")}
|
||||
accessibilityHint={t("accessibility.play_hint")}
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
{...props}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -31,6 +32,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { sessions, isLoading } = useAllSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
const handlePlayInSession = async (sessionId: string) => {
|
||||
if (!api || !item.Id) return;
|
||||
|
||||
@@ -65,7 +67,9 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
<View style={styles.centeredView}>
|
||||
<View style={styles.modalView}>
|
||||
<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)}>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
@@ -78,7 +82,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</View>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<Text style={styles.noSessionsText}>
|
||||
No active sessions found
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
</Text>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -98,7 +102,7 @@ export const PlayInRemoteSessionButton: React.FC<Props> = ({
|
||||
</Text>
|
||||
{session.NowPlayingItem && (
|
||||
<Text style={styles.nowPlaying} numberOfLines={1}>
|
||||
Now playing:{" "}
|
||||
{t("home.sessions.now_playing")}{" "}
|
||||
{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 { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { Text } from "./common/Text";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
@@ -22,7 +23,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
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]);
|
||||
|
||||
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 type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
if (Platform.isTV) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
const isAggregateType = (item: BaseItemDto) =>
|
||||
item.Type === "Series" || item.Type === "BoxSet";
|
||||
|
||||
// TV sizes are scaled relative to a 1920×1080 reference (see scaleSize).
|
||||
const tvBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: scaleSize(8),
|
||||
right: scaleSize(8),
|
||||
height: scaleSize(28),
|
||||
borderRadius: scaleSize(14),
|
||||
backgroundColor: "rgba(255,255,255,0.92)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
// Mobile uses raw dp — no scaling.
|
||||
const mobileBadgeBase: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#9333ea",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the unplayed-episode count badge for Series/BoxSet items that still
|
||||
* have episodes left to watch. Returns null for non-aggregate types, fully
|
||||
* watched items, or items with no unplayed count, so it is safe to mount
|
||||
* unconditionally as an overlay (e.g. on top of the tvOS glass poster, where
|
||||
* the watched checkmark is drawn natively and only the count needs RN).
|
||||
*/
|
||||
export const UnplayedCountBadge: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
({ item }) => {
|
||||
if (!isAggregateType(item)) return null;
|
||||
if (item.UserData?.Played) return null;
|
||||
const unplayed = item.UserData?.UnplayedItemCount ?? 0;
|
||||
if (unplayed <= 0) return null;
|
||||
// Cap at 1k+ to keep the badge compact (jellyfin-web caps at 99+).
|
||||
const label = unplayed >= 1000 ? "1k+" : String(unplayed);
|
||||
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(255,255,255,0.9)",
|
||||
borderRadius: 14,
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
style={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
return (
|
||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const isMovieOrEpisode = item.Type === "Movie" || item.Type === "Episode";
|
||||
const isAggregate = isAggregateType(item);
|
||||
const isPlayed = item.UserData?.Played === true;
|
||||
|
||||
if (Platform.isTV) {
|
||||
// Fully watched → white checkmark badge (top-right)
|
||||
if (isPlayed && (isMovieOrEpisode || isAggregate)) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
}
|
||||
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
return (
|
||||
<>
|
||||
{item.UserData?.Played === false &&
|
||||
(item.Type === "Movie" || item.Type === "Episode") && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
{/* Strict === false: items without UserData (unknown state) get no ribbon */}
|
||||
{isMovieOrEpisode && item.UserData?.Played === false && (
|
||||
<View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45' />
|
||||
)}
|
||||
|
||||
{/* Fully watched Series/BoxSet → small purple checkmark */}
|
||||
{isAggregate && isPlayed && (
|
||||
<View style={[mobileBadgeBase, { width: 20 }]}>
|
||||
<Ionicons name='checkmark' size={13} color='white' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Series/BoxSet with remaining episodes → count badge */}
|
||||
<UnplayedCountBadge item={item} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||
import { Platform } from "react-native";
|
||||
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'
|
||||
{...pressableProps}
|
||||
>
|
||||
<Ionicons name='arrow-back' size={24} color='white' />
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -36,10 +36,10 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
intensity={100}
|
||||
className='overflow-hidden rounded-full p-2'
|
||||
>
|
||||
<Ionicons
|
||||
<Feather
|
||||
className='drop-shadow-2xl'
|
||||
name='arrow-back'
|
||||
size={24}
|
||||
name='chevron-left'
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</BlurView>
|
||||
@@ -49,13 +49,18 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
return (
|
||||
<Pressable
|
||||
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}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons
|
||||
<Feather
|
||||
className='drop-shadow-2xl'
|
||||
name='arrow-back'
|
||||
size={24}
|
||||
name='chevron-left'
|
||||
size={28}
|
||||
color='white'
|
||||
/>
|
||||
</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 { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
TouchableOpacity,
|
||||
@@ -35,6 +35,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
}
|
||||
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { cancelDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
@@ -173,7 +174,9 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
|
||||
{isTranscoding && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
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(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
const seriesId = items[0]?.SeriesId;
|
||||
return seriesId ? storage.getString(seriesId) : undefined;
|
||||
}, [items[0]?.SeriesId]);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () =>
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { FilterSheetContent } from "./FilterSheetContent";
|
||||
|
||||
interface FilterButtonProps<T> extends ViewProps {
|
||||
id: string;
|
||||
disableSearch?: boolean;
|
||||
queryKey: string;
|
||||
values: T[];
|
||||
title: string;
|
||||
set: (value: T[]) => void;
|
||||
queryFn: (params: any) => Promise<any>;
|
||||
searchFilter?: (item: T, query: string) => boolean;
|
||||
renderItemLabel: (item: T) => React.ReactNode;
|
||||
renderItemLabel: (item: T) => string;
|
||||
multiple?: boolean;
|
||||
icon?: "filter" | "sort";
|
||||
}
|
||||
@@ -27,13 +25,11 @@ export const FilterButton = <T,>({
|
||||
values, // selected values
|
||||
title,
|
||||
renderItemLabel,
|
||||
searchFilter,
|
||||
disableSearch = false,
|
||||
multiple = false,
|
||||
icon = "filter",
|
||||
...props
|
||||
}: FilterButtonProps<T>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const { data: filters } = useQuery<T[]>({
|
||||
queryKey: ["filters", title, queryKey, id],
|
||||
@@ -42,61 +38,59 @@ export const FilterButton = <T,>({
|
||||
enabled: !!id && !!queryFn && !!queryKey,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
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>
|
||||
const openSheet = () => {
|
||||
if (!filters?.length) return;
|
||||
showModal(
|
||||
<FilterSheetContent<T>
|
||||
title={title}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
data={filters}
|
||||
values={values}
|
||||
initialValues={values}
|
||||
set={set}
|
||||
renderItemLabel={renderItemLabel}
|
||||
searchFilter={searchFilter}
|
||||
disableSearch={disableSearch}
|
||||
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 { useDownload } from "@/providers/DownloadProvider";
|
||||
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 { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
@@ -89,6 +93,9 @@ const HomeMobile = () => {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
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
|
||||
// (primary path is the LibraryChanged WebSocket event).
|
||||
@@ -97,7 +104,9 @@ const HomeMobile = () => {
|
||||
// Show intro modal on first launch
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
showIntro();
|
||||
}, 1000);
|
||||
@@ -106,7 +115,7 @@ const HomeMobile = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [showIntro]);
|
||||
}, [showIntro, pendingAccountSave]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
@@ -221,22 +230,33 @@ const HomeMobile = () => {
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
// Use getItems (not getLatestMedia) so we get item-level results
|
||||
// filtered by type from a specific library. getLatestMedia is
|
||||
// episode-oriented and groups results, which drops Series when
|
||||
// combined with a parentId + includeItemTypes filter.
|
||||
//
|
||||
// The specific reason for this is jellyfin 12.0 returns episodes, seasons, or shows,
|
||||
// but we only handle shows in our recently added in [shows] section. So we need to filter by type at the item level.
|
||||
//
|
||||
// For Series we sort by DateLastContentAdded so shows bubble up when
|
||||
// a new episode is added (series cards for new episodes, matching how
|
||||
// Jellyfin's "Latest" row worked pre-12.0). Movies use DateCreated.
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId,
|
||||
includeItemTypes,
|
||||
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",
|
||||
pageSize,
|
||||
@@ -250,9 +270,7 @@ const HomeMobile = () => {
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Colors } from "../../constants/Colors";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
}, [isSuccess, onLoaded]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||
const allItems = useMemo(() => {
|
||||
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
`}
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ContinueWatchingPoster
|
||||
item={item}
|
||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||
/>
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
// 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 { settings } = useSettings();
|
||||
|
||||
const allItems = useMemo(() => {
|
||||
const items = data?.pages.flat() ?? [];
|
||||
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
hasTVPreferredFocus={isFirstItem}
|
||||
onFocus={() => handleItemFocus(item)}
|
||||
width={itemWidth}
|
||||
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showItemActions,
|
||||
handleItemFocus,
|
||||
ITEM_GAP,
|
||||
settings?.useEpisodeImagesForNextUp,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Show skeleton if loading OR if lazy loading is enabled and not in view yet
|
||||
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
|
||||
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
`}
|
||||
>
|
||||
{item.Type === "Episode" && orientation === "horizontal" && (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ContinueWatchingPoster
|
||||
item={item}
|
||||
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
|
||||
/>
|
||||
)}
|
||||
{item.Type === "Episode" && orientation === "vertical" && (
|
||||
<SeriesPoster item={item} />
|
||||
|
||||
@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
const posterUrl = useMemo(() => {
|
||||
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.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
if (item.ParentThumbItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
if (item.SeriesId) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||
|
||||
@@ -23,9 +23,11 @@ export const ListGroup: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||
{title}
|
||||
</Text>
|
||||
{title ? (
|
||||
<Text className='ml-4 mb-1 uppercase text-[#8E8D91] text-xs'>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
<View
|
||||
style={[]}
|
||||
className='flex flex-col rounded-xl overflow-hidden pl-0 bg-neutral-900'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -32,14 +33,22 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
disabledByAdmin = false,
|
||||
...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;
|
||||
// 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)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isDisabled}
|
||||
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)}
|
||||
>
|
||||
<ListItemContent
|
||||
@@ -58,7 +67,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<ListItemContent
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -22,6 +23,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
disabled = false,
|
||||
refSetter,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur } = useTVFocusAnimation({
|
||||
scaleAmount: 1,
|
||||
@@ -68,7 +70,7 @@ export const TVGuideProgramCell: React.FC<TVGuideProgramCellProps> = ({
|
||||
<Text
|
||||
style={[styles.liveBadgeText, { fontSize: typography.callout }]}
|
||||
>
|
||||
LIVE
|
||||
{t("player.live")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -235,7 +235,7 @@ export const TVLiveTVPage: React.FC = () => {
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Live TV
|
||||
{t("live_tv.title")}
|
||||
</Text>
|
||||
|
||||
{/* Tab Bar */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -20,14 +20,16 @@ import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { QuickConnectCodeModal } from "@/components/login/QuickConnectCodeModal";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
import {
|
||||
apiAtom,
|
||||
pendingAccountSaveAtom,
|
||||
useJellyfin,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import type { SavedServer } from "@/utils/secureCredentials";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
@@ -35,6 +37,7 @@ const CredentialsSchema = z.object({
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
@@ -42,9 +45,11 @@ export const Login: React.FC = () => {
|
||||
login,
|
||||
removeServer,
|
||||
initiateQuickConnect,
|
||||
stopQuickConnectPolling,
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -64,13 +69,50 @@ export const Login: React.FC = () => {
|
||||
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 [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
// Tracks an in-flight Quick Connect attempt (code issued, provider polling).
|
||||
const [quickConnectActive, setQuickConnectActive] = useState(false);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -117,55 +159,34 @@ export const Login: React.FC = () => {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (!result.success) return;
|
||||
|
||||
if (saveAccount) {
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
await performLogin(credentials.username, credentials.password);
|
||||
const ok = await performLogin(credentials.username, credentials.password);
|
||||
// The protection picker shows AFTER a successful login (global modal) —
|
||||
// never for a failed one.
|
||||
if (ok && saveAccount) {
|
||||
setPendingAccountSave({ serverName });
|
||||
}
|
||||
};
|
||||
|
||||
const performLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
options?: {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
},
|
||||
) => {
|
||||
): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password, serverName, options);
|
||||
await login(username, password, serverName);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
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 {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
Alert.alert(
|
||||
t("login.quick_connect"),
|
||||
t("login.enter_code_to_login", { code: code }),
|
||||
[
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
],
|
||||
);
|
||||
setQuickConnectActive(true);
|
||||
setQuickConnectCode(code);
|
||||
}
|
||||
} catch (_error) {
|
||||
Alert.alert(
|
||||
@@ -402,7 +416,7 @@ export const Login: React.FC = () => {
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label='Server URL'
|
||||
aria-label={t("server.server_url")}
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
@@ -444,14 +458,11 @@ export const Login: React.FC = () => {
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
{/* Dismissing only hides the code — polling continues so the login still
|
||||
completes if the code is authorized from another device afterwards. */}
|
||||
<QuickConnectCodeModal
|
||||
code={quickConnectCode}
|
||||
onClose={() => setQuickConnectCode(null)}
|
||||
/>
|
||||
</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 {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
} finally {
|
||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured"),
|
||||
t("login.an_unexpected_error_occurred"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("login.an_unexpected_error_occured");
|
||||
: t("login.an_unexpected_error_occurred");
|
||||
Alert.alert(t("login.connection_failed"), message);
|
||||
goToQRScreen();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useState } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { WatchedIndicator } from "../WatchedIndicator";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -52,6 +53,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,7 +118,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
disableSearch={true}
|
||||
/>
|
||||
<FilterButton
|
||||
id={orderFilterId}
|
||||
@@ -128,7 +127,6 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
disableSearch={true}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
@@ -88,6 +89,8 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
showDiscover,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showDiscover) {
|
||||
return null;
|
||||
}
|
||||
@@ -101,13 +104,13 @@ export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
}}
|
||||
>
|
||||
<TVSearchTabBadge
|
||||
label='Library'
|
||||
label={t("search.library")}
|
||||
isSelected={searchType === "Library"}
|
||||
onPress={() => setSearchType("Library")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TVSearchTabBadge
|
||||
label='Discover'
|
||||
label={t("search.discover")}
|
||||
isSelected={searchType === "Discover"}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
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 useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -27,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
|
||||
<ListGroup title={t("home.settings.appearance.title")} className=''>
|
||||
<ListItem
|
||||
title={t("home.settings.other.show_custom_menu_links")}
|
||||
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onPress={() =>
|
||||
Linking.openURL(
|
||||
@@ -34,7 +36,7 @@ export const AppearanceSettings: React.FC = () => {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.showCustomMenuLinks}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -44,31 +46,51 @@ export const AppearanceSettings: React.FC = () => {
|
||||
</ListItem>
|
||||
<ListItem
|
||||
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}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
</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
|
||||
onPress={() =>
|
||||
router.push("/settings/appearance/hide-libraries/page")
|
||||
}
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideRemoteSessionButton}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ hideRemoteSessionButton: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
@@ -135,7 +135,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
title={t("home.settings.audio.set_audio_track")}
|
||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.rememberAudioSelections}
|
||||
disabled={pluginSettings?.rememberAudioSelections?.locked}
|
||||
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 { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -9,7 +10,7 @@ export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||
<View {...props}>
|
||||
<ListGroup title={"Chromecast"}>
|
||||
<ListItem title={"Enable H265 for Chromecast"}>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.enableH265ForChromecast}
|
||||
onValueChange={(enableH265ForChromecast) =>
|
||||
updateSettings({ enableH265ForChromecast })
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { Switch } from "react-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
@@ -39,7 +39,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.enableHorizontalSwipeSkip}
|
||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||
@@ -55,7 +55,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.enableLeftSideBrightnessSwipe}
|
||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||
@@ -71,7 +71,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.enableRightSideVolumeSwipe}
|
||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||
@@ -87,7 +87,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.hideVolumeSlider}
|
||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||
onValueChange={(hideVolumeSlider) =>
|
||||
@@ -103,7 +103,7 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
)}
|
||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.hideBrightnessSlider}
|
||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||
onValueChange={(hideBrightnessSlider) =>
|
||||
|
||||
@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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<
|
||||
string | undefined
|
||||
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
|
||||
string | 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({
|
||||
mutationFn: async () => {
|
||||
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
|
||||
throw new Error("Missing server url");
|
||||
if (!effectiveServerUrl) throw new Error("Missing server url");
|
||||
if (!user?.Name)
|
||||
throw new Error("Missing required information for login");
|
||||
const jellyseerrTempApi = new JellyseerrApi(
|
||||
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
|
||||
const testResult = await jellyseerrTempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setJellyseerrUser(user);
|
||||
updateSettings({ jellyseerrServerUrl });
|
||||
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
|
||||
},
|
||||
onError: () => {
|
||||
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'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url")}
|
||||
</Text>
|
||||
</View>
|
||||
<Input
|
||||
className='border border-neutral-800 mb-2'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
<View className='flex flex-col shrink mb-2'>
|
||||
<Text className='text-xs text-gray-600'>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
</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}
|
||||
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'>
|
||||
{t("home.settings.plugins.jellyseerr.password")}
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
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 { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const KefinTweaksSettings = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isEnabled = settings?.useKefinTweaks ?? false;
|
||||
const locked = pluginSettings?.useKefinTweaks?.locked === true;
|
||||
|
||||
return (
|
||||
<View className=''>
|
||||
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
|
||||
<Text className='text-xs text-red-600 mb-2'>
|
||||
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
</Text>
|
||||
|
||||
<View className='flex flex-row items-center justify-between mt-2'>
|
||||
<Text className='text-white'>
|
||||
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
trackColor={{ false: "#555", true: "purple" }}
|
||||
thumbColor={isEnabled ? "#fff" : "#ccc"}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
disabledByAdmin={locked}
|
||||
>
|
||||
<SettingSwitch
|
||||
value={isEnabled}
|
||||
disabled={locked}
|
||||
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, TouchableOpacity, View } from "react-native";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { useWifiSSID } from "@/hooks/useWifiSSID";
|
||||
import { openLocationSettings } from "@/modules/wifi-ssid";
|
||||
import { useServerUrl } from "@/providers/ServerUrlProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
@@ -26,16 +28,26 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
|
||||
|
||||
interface StatusDisplayProps {
|
||||
currentSSID: string | null;
|
||||
connectedToWifi: boolean;
|
||||
isUsingLocalUrl: boolean;
|
||||
locationBlocked: boolean;
|
||||
onOpenLocationSettings: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function StatusDisplay({
|
||||
currentSSID,
|
||||
connectedToWifi,
|
||||
isUsingLocalUrl,
|
||||
locationBlocked,
|
||||
onOpenLocationSettings,
|
||||
t,
|
||||
}: 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
|
||||
? t("home.settings.network.local")
|
||||
: t("home.settings.network.remote");
|
||||
@@ -55,6 +67,23 @@ function StatusDisplay({
|
||||
</Text>
|
||||
<Text className={urlTypeColor}>{urlType}</Text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +91,17 @@ function StatusDisplay({
|
||||
export function LocalNetworkSettings(): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
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 [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||
@@ -147,7 +186,10 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
||||
title={t("home.settings.network.auto_switch_enabled")}
|
||||
subtitle={t("home.settings.network.auto_switch_description")}
|
||||
>
|
||||
<Switch value={config.enabled} onValueChange={handleToggleEnabled} />
|
||||
<SettingSwitch
|
||||
value={config.enabled}
|
||||
onValueChange={handleToggleEnabled}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
|
||||
@@ -195,18 +237,23 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
||||
)}
|
||||
</ListGroup>
|
||||
|
||||
<View className='py-2'>
|
||||
<Button
|
||||
onPress={handleAddCurrentNetwork}
|
||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||
>
|
||||
{addNetworkButtonText}
|
||||
</Button>
|
||||
</View>
|
||||
{!locationBlocked && (
|
||||
<View className='py-2'>
|
||||
<Button
|
||||
onPress={handleAddCurrentNetwork}
|
||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||
>
|
||||
{addNetworkButtonText}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<StatusDisplay
|
||||
currentSSID={currentSSID}
|
||||
connectedToWifi={connectedToWifi}
|
||||
isUsingLocalUrl={isUsingLocalUrl}
|
||||
locationBlocked={locationBlocked}
|
||||
onOpenLocationSettings={handleOpenLocationSettings}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
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 { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
@@ -17,20 +19,21 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
const isTv = Platform.isTV;
|
||||
const media = useMedia();
|
||||
const { settings, updateSettings } = media;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const alignXOptions: AlignX[] = ["left", "center", "right"];
|
||||
const alignYOptions: AlignY[] = ["top", "center", "bottom"];
|
||||
|
||||
const alignXLabels: Record<AlignX, string> = {
|
||||
left: "Left",
|
||||
center: "Center",
|
||||
right: "Right",
|
||||
left: t("home.settings.subtitles.align.left"),
|
||||
center: t("home.settings.subtitles.align.center"),
|
||||
right: t("home.settings.subtitles.align.right"),
|
||||
};
|
||||
|
||||
const alignYLabels: Record<AlignY, string> = {
|
||||
top: "Top",
|
||||
center: "Center",
|
||||
bottom: "Bottom",
|
||||
top: t("home.settings.subtitles.align.top"),
|
||||
center: t("home.settings.subtitles.align.center"),
|
||||
bottom: t("home.settings.subtitles.align.bottom"),
|
||||
};
|
||||
|
||||
const alignXOptionGroups = useMemo(() => {
|
||||
@@ -60,16 +63,18 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
title='MPV Subtitle Settings'
|
||||
title={t("home.settings.subtitles.mpv_settings_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
Advanced subtitle customization for MPV player
|
||||
{t("home.settings.subtitles.mpv_settings_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{!isTv && (
|
||||
<>
|
||||
<ListItem title='Vertical Margin'>
|
||||
<ListItem
|
||||
title={t("home.settings.subtitles.mpv_subtitle_margin_y")}
|
||||
>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleMarginY ?? 0}
|
||||
step={5}
|
||||
@@ -81,7 +86,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title='Horizontal Alignment'>
|
||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_x")}>
|
||||
<PlatformDropdown
|
||||
groups={alignXOptionGroups}
|
||||
trigger={
|
||||
@@ -96,11 +101,11 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title='Horizontal Alignment'
|
||||
title={t("home.settings.subtitles.mpv_subtitle_align_x")}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem title='Vertical Alignment'>
|
||||
<ListItem title={t("home.settings.subtitles.mpv_subtitle_align_y")}>
|
||||
<PlatformDropdown
|
||||
groups={alignYOptionGroups}
|
||||
trigger={
|
||||
@@ -115,14 +120,14 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title='Vertical Alignment'
|
||||
title={t("home.settings.subtitles.mpv_subtitle_align_y")}
|
||||
/>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ListItem title='Opaque Background'>
|
||||
<Switch
|
||||
<ListItem title={t("home.settings.subtitles.opaque_background")}>
|
||||
<SettingSwitch
|
||||
value={settings.mpvSubtitleBackgroundEnabled ?? false}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ mpvSubtitleBackgroundEnabled: value })
|
||||
@@ -131,7 +136,7 @@ export const MpvSubtitleSettings: React.FC<Props> = ({ ...props }) => {
|
||||
</ListItem>
|
||||
|
||||
{settings.mpvSubtitleBackgroundEnabled && (
|
||||
<ListItem title='Background Opacity'>
|
||||
<ListItem title={t("home.settings.subtitles.background_opacity")}>
|
||||
<Stepper
|
||||
value={settings.mpvSubtitleBackgroundOpacity ?? 75}
|
||||
step={5}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch, View } from "react-native";
|
||||
import { Linking, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
@@ -132,7 +133,7 @@ export const OtherSettings: React.FC = () => {
|
||||
title={t("home.settings.other.safe_area_in_controls")}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.safeAreaInControlsEnabled}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -150,7 +151,7 @@ export const OtherSettings: React.FC = () => {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.showCustomMenuLinks}
|
||||
disabled={pluginSettings?.showCustomMenuLinks?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -188,7 +189,7 @@ export const OtherSettings: React.FC = () => {
|
||||
title={t("home.settings.other.disable_haptic_feedback")}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.disableHapticFeedback}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
onValueChange={(disableHapticFeedback) =>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
@@ -115,7 +116,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled}>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||
<ListGroup title={t("home.settings.other.other_title")} className='mb-4'>
|
||||
<ListItem
|
||||
title={t("home.settings.other.video_orientation")}
|
||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||
@@ -146,7 +147,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.safe_area_in_controls")}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.safeAreaInControlsEnabled}
|
||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||
onValueChange={(value) =>
|
||||
@@ -205,7 +206,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.disable_haptic_feedback")}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.disableHapticFeedback}
|
||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||
onValueChange={(disableHapticFeedback) =>
|
||||
@@ -218,7 +219,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
title={t("home.settings.other.auto_play_next_episode")}
|
||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.autoPlayNextEpisode}
|
||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||
onValueChange={(autoPlayNextEpisode) =>
|
||||
|
||||
@@ -20,12 +20,7 @@ export const PluginSettings = () => {
|
||||
>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
|
||||
title={"Jellyseerr"}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||
title='Marlin Search'
|
||||
title='Jellyseerr'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
|
||||
title='Streamystats'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/marlin-search/page")}
|
||||
title='Marlin Search'
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
|
||||
title='KefinTweaks'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
@@ -5,11 +6,13 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { requireOptionalNativeModule } from "expo-modules-core";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Button } from "../Button";
|
||||
@@ -58,7 +61,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
successHapticFeedback();
|
||||
Alert.alert(
|
||||
t("home.settings.quick_connect.success"),
|
||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||
);
|
||||
setQuickConnectCode(undefined);
|
||||
bottomSheetModalRef?.current?.close();
|
||||
@@ -79,6 +82,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
}
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
@@ -130,6 +142,15 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||
style={{ paddingHorizontal: 16 }}
|
||||
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>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
||||
used: (app.total - app.remaining) / app.total,
|
||||
};
|
||||
},
|
||||
// Keep the bar moving while a download is writing to disk.
|
||||
refetchInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
successHapticFeedback();
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
const onDeleteClicked = () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
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) => {
|
||||
@@ -102,7 +125,7 @@ export const StorageSettings = () => {
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<ListGroup>
|
||||
<ListGroup className={Platform.OS === "android" ? "mt-4" : undefined}>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { SettingSwitch } from "@/components/common/SettingSwitch";
|
||||
import { Stepper } from "@/components/inputs/Stepper";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -98,6 +98,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<ListGroup
|
||||
className='mb-4'
|
||||
title={t("home.settings.subtitles.subtitle_title")}
|
||||
description={
|
||||
<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")}
|
||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||
>
|
||||
<Switch
|
||||
<SettingSwitch
|
||||
value={settings.rememberSubtitleSelections}
|
||||
disabled={pluginSettings?.rememberSubtitleSelections?.locked}
|
||||
onValueChange={(value) =>
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import {
|
||||
UnplayedCountBadge,
|
||||
WatchedIndicator,
|
||||
} from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
@@ -69,6 +72,9 @@ export interface TVPosterCardProps {
|
||||
|
||||
/** Custom image URL getter - if not provided, uses smart URL logic */
|
||||
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",
|
||||
scaleAmount = 1.05,
|
||||
imageUrlGetter,
|
||||
preferEpisodeImage = false,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
@@ -136,9 +143,14 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
if (orientation === "horizontal") {
|
||||
// Episode: prefer series thumb image for consistent look (like hero section)
|
||||
if (item.Type === "Episode") {
|
||||
// First try parent/series thumb (horizontal series artwork)
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
// Opt-in: use the episode's own image instead of the series thumb.
|
||||
if (preferEpisodeImage && item.ImageTags?.Primary) {
|
||||
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
|
||||
if (item.ImageTags?.Primary) {
|
||||
@@ -170,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
item,
|
||||
width: width * 2, // 2x for quality on large screens
|
||||
});
|
||||
}, [api, item, orientation, width, imageUrlGetter]);
|
||||
}, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
|
||||
|
||||
// Progress calculation
|
||||
const progress = useMemo(() => {
|
||||
@@ -427,6 +439,12 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
{/*
|
||||
The glass view draws the watched checkmark natively but cannot show
|
||||
an unplayed-episode count, so render it as an RN overlay on top.
|
||||
Returns null when not applicable (non-series / fully watched).
|
||||
*/}
|
||||
{showWatchedIndicator && <UnplayedCountBadge item={item} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -459,7 +477,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
<WatchedIndicator item={item} />
|
||||
{showWatchedIndicator && <WatchedIndicator item={item} />}
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -28,6 +29,7 @@ export const TVSubtitleResultCard = React.forwardRef<
|
||||
const styles = createStyles(typography);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{result.hearingImpaired && (
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
@@ -34,11 +33,8 @@ interface BottomControlsProps {
|
||||
showRemoteBubble: boolean;
|
||||
currentTime: number;
|
||||
remainingTime: number;
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
nextItem?: BaseItemDto | null;
|
||||
handleNextEpisodeAutoPlay: () => void;
|
||||
handleNextEpisodeManual: () => void;
|
||||
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
showRemoteBubble,
|
||||
currentTime,
|
||||
remainingTime,
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
nextItem,
|
||||
handleNextEpisodeAutoPlay,
|
||||
handleNextEpisodeManual,
|
||||
@@ -180,21 +173,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
{/* Smart Skip Credits behavior:
|
||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||
- Show "Next Episode" if credits extend to video end AND next episode exists */}
|
||||
<SkipButton
|
||||
showButton={
|
||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||
}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
{settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount <
|
||||
|
||||
@@ -27,7 +27,7 @@ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
|
||||
}
|
||||
>
|
||||
<Text className='text-2xl font-bold text-white py-4 '>
|
||||
Are you still watching ?
|
||||
{t("player.still_watching")}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() => {
|
||||
|
||||
@@ -3,9 +3,10 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useKeyEventListener } from "expo-key-event";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import { Platform, StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
type SharedValue,
|
||||
@@ -38,6 +39,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -110,6 +112,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
// Set when the user manually presses Skip Credits. Once set, the next-episode
|
||||
// countdown is allowed to appear but will NOT auto-fire — the user must tap
|
||||
// it. This prevents a (possibly wrong) credit end timestamp from landing in
|
||||
// the end zone and auto-advancing to the next episode. Resets on unmount
|
||||
// (navigation to the next episode or away).
|
||||
const [suppressAutoNextEpisode, setSuppressAutoNextEpisode] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
@@ -205,6 +213,22 @@ export const Controls: FC<Props> = ({
|
||||
play,
|
||||
});
|
||||
|
||||
useKeyEventListener((e) => {
|
||||
if (episodeView || showAudioSlider) return;
|
||||
if (e?.eventType !== "press") return;
|
||||
const key = e.key;
|
||||
|
||||
if (key === " " || key === "Spacebar" || key === "Space") {
|
||||
togglePlay();
|
||||
} else if (!Platform.isTV && key === "ArrowLeft") {
|
||||
// Exclude TV platforms to prevent conflicts with the remote control,
|
||||
// which uses the same arrow keys for directional UI navigation.
|
||||
handleSkipBackward();
|
||||
} else if (!Platform.isTV && key === "ArrowRight") {
|
||||
handleSkipForward();
|
||||
}
|
||||
});
|
||||
|
||||
// Time management hook
|
||||
const { currentTime, remainingTime } = useVideoTime({
|
||||
progress,
|
||||
@@ -338,6 +362,23 @@ export const Controls: FC<Props> = ({
|
||||
maxMs,
|
||||
);
|
||||
|
||||
// Whether the "Next Episode" countdown will actually be rendered. The Skip
|
||||
// Credits button yields to it only when this is true; if autoplay is
|
||||
// disabled or its episode limit is reached, Skip Credits must stay available
|
||||
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
|
||||
const willShowNextEpisode =
|
||||
!!nextItem &&
|
||||
settings.autoPlayNextEpisode !== false &&
|
||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
|
||||
|
||||
// Wrap skipCredit so a manual press also arms the auto-advance suppression
|
||||
// (see suppressAutoNextEpisode). The underlying seek is unchanged.
|
||||
const handleSkipCredit = useCallback(() => {
|
||||
setSuppressAutoNextEpisode(true);
|
||||
skipCredit();
|
||||
}, [skipCredit]);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
if (!item || !settings) {
|
||||
@@ -447,8 +488,12 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
// Add a memoized handler for autoplay next episode
|
||||
const handleNextEpisodeAutoPlay = useCallback(() => {
|
||||
// If the user manually skipped credits, don't auto-advance — let the
|
||||
// countdown sit so they can tap it. Guards against wrong credit end
|
||||
// timestamps landing in the end zone and skipping the episode's tail.
|
||||
if (suppressAutoNextEpisode) return;
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
}, [goToNextItem, suppressAutoNextEpisode]);
|
||||
|
||||
// Add a memoized handler for manual next episode
|
||||
const handleNextEpisodeManual = useCallback(() => {
|
||||
@@ -570,11 +615,8 @@ export const Controls: FC<Props> = ({
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
currentTime={currentTime}
|
||||
remainingTime={remainingTime}
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={skipCredit}
|
||||
nextItem={nextItem}
|
||||
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
|
||||
handleNextEpisodeManual={handleNextEpisodeManual}
|
||||
@@ -594,6 +636,17 @@ export const Controls: FC<Props> = ({
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
/>
|
||||
</Animated.View>
|
||||
{/* Skip Intro / Skip Credits float independently of the controls so
|
||||
they're visible (and tappable) without summoning the controls. */}
|
||||
<SkipSegmentOverlay
|
||||
showSkipButton={showSkipButton}
|
||||
showSkipCreditButton={showSkipCreditButton}
|
||||
hasContentAfterCredits={hasContentAfterCredits}
|
||||
willShowNextEpisode={willShowNextEpisode}
|
||||
skipIntro={skipIntro}
|
||||
skipCredit={handleSkipCredit}
|
||||
controlsVisible={showControls}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{settings.maxAutoPlayEpisodeCount.value !== -1 && (
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { compareTracksForMenu } from "@/utils/jellyfin/subtitleUtils";
|
||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
import { useVideoContext } from "./contexts/VideoContext";
|
||||
@@ -260,6 +261,12 @@ export const Controls: FC<Props> = ({
|
||||
// Track if play button should have focus (when showing controls via up/down D-pad)
|
||||
const [focusPlayButton, setFocusPlayButton] = useState(false);
|
||||
|
||||
// Set when the user manually presses Skip Credits. Once set, the next-episode
|
||||
// countdown is allowed to appear but will NOT auto-fire — the user must select
|
||||
// it. Prevents a (possibly wrong) credit end timestamp from landing in the end
|
||||
// zone and auto-advancing to the next episode. Resets on unmount.
|
||||
const [suppressAutoNextEpisode, setSuppressAutoNextEpisode] = useState(false);
|
||||
|
||||
// State for progress bar focus and focus guide refs
|
||||
const [isProgressBarFocused, setIsProgressBarFocused] = useState(false);
|
||||
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
||||
@@ -317,8 +324,10 @@ export const Controls: FC<Props> = ({
|
||||
try {
|
||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||
// Skip streams without a real index: `?? -1` would alias them to the
|
||||
// "disable subtitles" sentinel and mis-route selection.
|
||||
return streams
|
||||
// "disable subtitles" sentinel and mis-route selection. Order like
|
||||
// jellyfin-web (embedded first, externals last, forced/default up).
|
||||
return [...streams]
|
||||
.sort(compareTracksForMenu)
|
||||
.filter((stream) => typeof stream.Index === "number")
|
||||
.map((stream) => {
|
||||
const index = stream.Index as number;
|
||||
@@ -564,6 +573,9 @@ export const Controls: FC<Props> = ({
|
||||
title: t("item_card.audio"),
|
||||
options: audioOptions,
|
||||
onSelect: handleAudioChange,
|
||||
// In-player audio selection navigates (replacePlayer while transcoding);
|
||||
// apply it after the modal is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [showOptions, t, audioOptions, handleAudioChange]);
|
||||
@@ -598,6 +610,9 @@ export const Controls: FC<Props> = ({
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
subtitleTracks: tracksWithoutDisable,
|
||||
currentSubtitleIndex: subtitleIndex ?? -1,
|
||||
// In-player selection can navigate (replacePlayer for burn-in switches);
|
||||
// apply it after the modal route is dismissed so it isn't swallowed.
|
||||
deferApplyUntilDismissed: true,
|
||||
onDisableSubtitles: () => {
|
||||
// Find and call the "Disable" track's setTrack from VideoContext
|
||||
const disableTrack = videoContextSubtitleTracks?.find(
|
||||
@@ -1081,10 +1096,21 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
goToNextItemRef.current = goToNextItem;
|
||||
|
||||
// Wrap skipCredit so a manual press also arms the auto-advance suppression
|
||||
// (see suppressAutoNextEpisode). The underlying seek is unchanged.
|
||||
const handleSkipCredit = useCallback(() => {
|
||||
setSuppressAutoNextEpisode(true);
|
||||
skipCredit();
|
||||
}, [skipCredit]);
|
||||
|
||||
const handleAutoPlayFinish = useCallback(() => {
|
||||
if (exitingRef.current) return;
|
||||
// If the user manually skipped credits, don't auto-advance — let the
|
||||
// countdown sit so they can select it. Guards against wrong credit end
|
||||
// timestamps landing in the end zone and skipping the episode's tail.
|
||||
if (suppressAutoNextEpisode) return;
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
}, [goToNextItem, suppressAutoNextEpisode]);
|
||||
|
||||
const topOverlayFocusTarget = skipSegmentRef ?? nextEpisodeRef;
|
||||
|
||||
@@ -1144,7 +1170,7 @@ export const Controls: FC<Props> = ({
|
||||
(hasContentAfterCredits || !nextItem) &&
|
||||
!isCountdownActive
|
||||
}
|
||||
onPress={skipCredit}
|
||||
onPress={handleSkipCredit}
|
||||
type='credits'
|
||||
controlsVisible={showControls}
|
||||
refSetter={setSkipSegmentRef}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
@@ -57,6 +58,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
@@ -127,8 +129,8 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
onPress={toggleOrientation}
|
||||
disabled={isTogglingOrientation}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
accessibilityLabel='Toggle screen orientation'
|
||||
accessibilityHint='Toggles the screen orientation between portrait and landscape'
|
||||
accessibilityLabel={t("accessibility.toggle_orientation")}
|
||||
accessibilityHint={t("accessibility.toggle_orientation_hint")}
|
||||
>
|
||||
<MaterialIcons
|
||||
name='screen-rotation'
|
||||
|
||||
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
125
components/video-player/controls/SkipSegmentOverlay.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import SkipButton from "./SkipButton";
|
||||
|
||||
interface Props {
|
||||
showSkipButton: boolean;
|
||||
showSkipCreditButton: boolean;
|
||||
hasContentAfterCredits: boolean;
|
||||
willShowNextEpisode: boolean;
|
||||
skipIntro: () => void;
|
||||
skipCredit: () => void;
|
||||
controlsVisible: boolean;
|
||||
}
|
||||
|
||||
// Offsets are relative to the safe-area insets so they hold in both portrait
|
||||
// and landscape (the insets move with the notch / home indicator).
|
||||
//
|
||||
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
|
||||
// spot and was working fine.
|
||||
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
|
||||
// of the chapters icon), while staying below the vertical volume
|
||||
// slider which sits at the vertical middle of the screen.
|
||||
const HIDDEN_BOTTOM = 24;
|
||||
const HIDDEN_RIGHT = 12;
|
||||
const VISIBLE_BOTTOM = 65;
|
||||
const VISIBLE_RIGHT = 42;
|
||||
const ANIM_DURATION = 250;
|
||||
|
||||
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
|
||||
// itself instantly via a `hidden` (display:none) class, which would preempt
|
||||
// the parent's opacity fade-out — lagging the flag keeps the button rendered
|
||||
// (and visible) while the fade plays out.
|
||||
const useDelayedHide = (value: boolean, duration: number): boolean => {
|
||||
const [display, setDisplay] = useState(value);
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setDisplay(true);
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(() => setDisplay(false), duration);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, duration]);
|
||||
return value || display;
|
||||
};
|
||||
|
||||
/**
|
||||
* Floating Skip Intro / Skip Credits buttons shown independently of the
|
||||
* player controls. They appear on their own during an intro or credits segment
|
||||
* without the user having to summon the controls.
|
||||
*/
|
||||
export const SkipSegmentOverlay: FC<Props> = ({
|
||||
showSkipButton,
|
||||
showSkipCreditButton,
|
||||
hasContentAfterCredits,
|
||||
willShowNextEpisode,
|
||||
skipIntro,
|
||||
skipCredit,
|
||||
controlsVisible,
|
||||
}) => {
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
|
||||
const showCredit =
|
||||
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
|
||||
const visible = showSkipButton || showCredit;
|
||||
|
||||
// Drive each SkipButton with a lagged flag so it stays visible while the
|
||||
// opacity fade-out plays, instead of disappearing the instant its segment
|
||||
// ends. `visible` above still drives opacity/pointerEvents immediately.
|
||||
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
|
||||
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
|
||||
|
||||
const opacity = useSharedValue(visible ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, {
|
||||
duration: ANIM_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
}, [visible, opacity]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
// Position is recomputed on render (no slide animation) so the button never
|
||||
// sweeps through an overlap zone while the controls toggle.
|
||||
const bottom =
|
||||
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
|
||||
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { right, bottom }, animatedStyle]}
|
||||
pointerEvents={visible ? "box-none" : "none"}
|
||||
>
|
||||
<SkipButton
|
||||
showButton={renderSkip}
|
||||
onPress={skipIntro}
|
||||
buttonText='Skip Intro'
|
||||
/>
|
||||
<SkipButton
|
||||
showButton={renderCredit}
|
||||
onPress={skipCredit}
|
||||
buttonText='Skip Credits'
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
zIndex: 15,
|
||||
},
|
||||
});
|
||||
@@ -1,560 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Easing,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
TVCancelButton,
|
||||
TVLanguageCard,
|
||||
TVSubtitleResultCard,
|
||||
TVTabButton,
|
||||
TVTrackCard,
|
||||
} from "@/components/tv";
|
||||
import {
|
||||
type SubtitleSearchResult,
|
||||
useRemoteSubtitles,
|
||||
} from "@/hooks/useRemoteSubtitles";
|
||||
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
||||
|
||||
interface TVSubtitleSheetProps {
|
||||
visible: boolean;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
subtitleTracks: MediaStream[];
|
||||
currentSubtitleIndex: number;
|
||||
onSubtitleIndexChange: (index: number) => void;
|
||||
onClose: () => void;
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
}
|
||||
|
||||
type TabType = "tracks" | "download";
|
||||
|
||||
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
|
||||
visible,
|
||||
item,
|
||||
mediaSourceId,
|
||||
subtitleTracks,
|
||||
currentSubtitleIndex,
|
||||
onSubtitleIndexChange,
|
||||
onClose,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
console.log(
|
||||
"[TVSubtitleSheet] visible:",
|
||||
visible,
|
||||
"tracks:",
|
||||
subtitleTracks.length,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
||||
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
||||
const firstTrackRef = useRef<View>(null);
|
||||
|
||||
const {
|
||||
hasOpenSubtitlesApiKey,
|
||||
isSearching,
|
||||
searchError,
|
||||
searchResults,
|
||||
search,
|
||||
downloadAsync,
|
||||
reset,
|
||||
} = useRemoteSubtitles({
|
||||
itemId: item.Id ?? "",
|
||||
item,
|
||||
mediaSourceId,
|
||||
});
|
||||
|
||||
const resetRef = useRef(reset);
|
||||
resetRef.current = reset;
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
||||
|
||||
const initialSelectedTrackIndex = useMemo(() => {
|
||||
if (currentSubtitleIndex === -1) return 0;
|
||||
const trackIdx = subtitleTracks.findIndex(
|
||||
(t) => t.Index === currentSubtitleIndex,
|
||||
);
|
||||
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
||||
}, [subtitleTracks, currentSubtitleIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
overlayOpacity.setValue(0);
|
||||
sheetTranslateY.setValue(300);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(sheetTranslateY, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setHasSearchedThisSession(false);
|
||||
setActiveTab("tracks");
|
||||
resetRef.current();
|
||||
setIsReady(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => setIsReady(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && activeTab === "download" && !hasSearchedThisSession) {
|
||||
search({ language: selectedLanguage });
|
||||
setHasSearchedThisSession(true);
|
||||
}
|
||||
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) {
|
||||
setIsTabContentReady(false);
|
||||
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
setIsTabContentReady(false);
|
||||
}, [activeTab, isReady]);
|
||||
|
||||
const handleLanguageSelect = useCallback(
|
||||
(code: string) => {
|
||||
setSelectedLanguage(code);
|
||||
search({ language: code });
|
||||
},
|
||||
[search],
|
||||
);
|
||||
|
||||
const handleTrackSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSubtitleIndexChange(index);
|
||||
onClose();
|
||||
},
|
||||
[onSubtitleIndexChange, onClose],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (result: SubtitleSearchResult) => {
|
||||
setDownloadingId(result.id);
|
||||
|
||||
try {
|
||||
const downloadResult = await downloadAsync(result);
|
||||
|
||||
if (downloadResult.type === "server") {
|
||||
onServerSubtitleDownloaded?.();
|
||||
} else if (downloadResult.type === "local" && downloadResult.path) {
|
||||
onLocalSubtitleDownloaded?.(downloadResult.path);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to download subtitle:", error);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
downloadAsync,
|
||||
onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded,
|
||||
onClose,
|
||||
],
|
||||
);
|
||||
|
||||
const displayLanguages = useMemo(
|
||||
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
||||
[],
|
||||
);
|
||||
|
||||
const trackOptions = useMemo(() => {
|
||||
const noneOption = {
|
||||
label: t("item_card.subtitles.none"),
|
||||
sublabel: undefined as string | undefined,
|
||||
value: -1,
|
||||
selected: currentSubtitleIndex === -1,
|
||||
};
|
||||
const options = subtitleTracks.map((track) => ({
|
||||
label:
|
||||
track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`,
|
||||
sublabel: track.Codec?.toUpperCase(),
|
||||
value: track.Index!,
|
||||
selected: track.Index === currentSubtitleIndex,
|
||||
}));
|
||||
return [noneOption, ...options];
|
||||
}, [subtitleTracks, currentSubtitleIndex, t]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheetContainer,
|
||||
{ transform: [{ translateY: sheetTranslateY }] },
|
||||
]}
|
||||
>
|
||||
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
|
||||
<TVFocusGuideView
|
||||
trapFocusUp
|
||||
trapFocusDown
|
||||
trapFocusLeft
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
{/* Header with tabs */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>
|
||||
{t("item_card.subtitles.label") || "Subtitles"}
|
||||
</Text>
|
||||
|
||||
{/* Tab bar */}
|
||||
<View style={styles.tabRow}>
|
||||
<TVTabButton
|
||||
label={t("item_card.subtitles.tracks") || "Tracks"}
|
||||
active={activeTab === "tracks"}
|
||||
onSelect={() => setActiveTab("tracks")}
|
||||
/>
|
||||
<TVTabButton
|
||||
label={t("player.download") || "Download"}
|
||||
active={activeTab === "download"}
|
||||
onSelect={() => setActiveTab("download")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tracks Tab Content */}
|
||||
{activeTab === "tracks" && isTabContentReady && (
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tracksScroll}
|
||||
contentContainerStyle={styles.tracksScrollContent}
|
||||
>
|
||||
{trackOptions.map((option, index) => (
|
||||
<TVTrackCard
|
||||
key={option.value}
|
||||
ref={
|
||||
index === initialSelectedTrackIndex
|
||||
? firstTrackRef
|
||||
: undefined
|
||||
}
|
||||
label={option.label}
|
||||
sublabel={option.sublabel}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
||||
onPress={() => handleTrackSelect(option.value)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Download Tab Content */}
|
||||
{activeTab === "download" && isTabContentReady && (
|
||||
<>
|
||||
{/* Language Selector */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.language") || "Language"}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.languageScroll}
|
||||
contentContainerStyle={styles.languageScrollContent}
|
||||
>
|
||||
{displayLanguages.map((lang, index) => (
|
||||
<TVLanguageCard
|
||||
key={lang.code}
|
||||
code={lang.code}
|
||||
name={lang.name}
|
||||
selected={selectedLanguage === lang.code}
|
||||
hasTVPreferredFocus={
|
||||
index === 0 &&
|
||||
(!searchResults || searchResults.length === 0)
|
||||
}
|
||||
onPress={() => handleLanguageSelect(lang.code)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{t("player.results") || "Results"}
|
||||
{searchResults && ` (${searchResults.length})`}
|
||||
</Text>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size='large' color='#fff' />
|
||||
<Text style={styles.loadingText}>
|
||||
{t("player.searching") || "Searching..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{searchError && !isSearching && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons
|
||||
name='alert-circle-outline'
|
||||
size={32}
|
||||
color='rgba(255,100,100,0.8)'
|
||||
/>
|
||||
<Text style={styles.errorText}>
|
||||
{t("player.search_failed") || "Search failed"}
|
||||
</Text>
|
||||
<Text style={styles.errorHint}>
|
||||
{!hasOpenSubtitlesApiKey
|
||||
? t("player.no_subtitle_provider") ||
|
||||
"No subtitle provider configured on server"
|
||||
: String(searchError)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{searchResults &&
|
||||
searchResults.length === 0 &&
|
||||
!isSearching &&
|
||||
!searchError && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons
|
||||
name='document-text-outline'
|
||||
size={32}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
{t("player.no_subtitles_found") ||
|
||||
"No subtitles found"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Results list */}
|
||||
{searchResults &&
|
||||
searchResults.length > 0 &&
|
||||
!isSearching && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.resultsScroll}
|
||||
contentContainerStyle={styles.resultsScrollContent}
|
||||
>
|
||||
{searchResults.map((result, index) => (
|
||||
<TVSubtitleResultCard
|
||||
key={result.id}
|
||||
result={result}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isDownloading={downloadingId === result.id}
|
||||
onPress={() => handleDownload(result)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Key hint if no fallback available */}
|
||||
{!hasOpenSubtitlesApiKey && (
|
||||
<View style={styles.apiKeyHint}>
|
||||
<Ionicons
|
||||
name='information-circle-outline'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
<Text style={styles.apiKeyHintText}>
|
||||
{t("player.add_opensubtitles_key_hint") ||
|
||||
"Add OpenSubtitles API key in settings for client-side fallback"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
{isReady && (
|
||||
<View style={styles.cancelButtonContainer}>
|
||||
<TVCancelButton
|
||||
onPress={onClose}
|
||||
label={t("common.cancel") || "Cancel"}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "70%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabRow: {
|
||||
flexDirection: "row",
|
||||
gap: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
tracksScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
tracksScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
languageScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
languageScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 10,
|
||||
},
|
||||
resultsScroll: {
|
||||
overflow: "visible",
|
||||
},
|
||||
resultsScrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
loadingContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
errorContainer: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "rgba(255,100,100,0.9)",
|
||||
marginTop: 8,
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
errorHint: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
textAlign: "center",
|
||||
},
|
||||
emptyContainer: {
|
||||
paddingVertical: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
},
|
||||
apiKeyHint: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 8,
|
||||
},
|
||||
apiKeyHintText: {
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
fontSize: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
paddingHorizontal: 48,
|
||||
paddingTop: 20,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet, Text, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -184,6 +185,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
@@ -312,13 +314,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.videoCodec && (
|
||||
<Text style={textStyle}>
|
||||
Video: {formatCodec(info.videoCodec)}
|
||||
{t("player.technical_info.video")} {formatCodec(info.videoCodec)}
|
||||
{info.fps ? ` @ ${formatFps(info.fps)} fps` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{info?.audioCodec && (
|
||||
<Text style={textStyle}>
|
||||
Audio: {formatCodec(info.audioCodec)}
|
||||
{t("player.technical_info.audio")} {formatCodec(info.audioCodec)}
|
||||
{streamInfo?.audioChannels
|
||||
? ` ${formatAudioChannels(streamInfo.audioChannels)}`
|
||||
: ""}
|
||||
@@ -326,12 +328,13 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{streamInfo?.subtitleCodec && (
|
||||
<Text style={textStyle}>
|
||||
Subtitle: {formatCodec(streamInfo.subtitleCodec)}
|
||||
{t("player.technical_info.subtitle")}{" "}
|
||||
{formatCodec(streamInfo.subtitleCodec)}
|
||||
</Text>
|
||||
)}
|
||||
{(info?.videoBitrate || info?.audioBitrate) && (
|
||||
<Text style={textStyle}>
|
||||
Bitrate:{" "}
|
||||
{t("player.technical_info.bitrate")}{" "}
|
||||
{info.videoBitrate
|
||||
? formatBitrate(info.videoBitrate)
|
||||
: info.audioBitrate
|
||||
@@ -341,7 +344,9 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.cacheSeconds !== undefined && (
|
||||
<Text style={textStyle}>
|
||||
Buffer: {info.cacheSeconds.toFixed(1)}s
|
||||
{t("player.technical_info.buffer_seconds", {
|
||||
seconds: info.cacheSeconds.toFixed(1),
|
||||
})}
|
||||
{info?.demuxerMaxBytes !== undefined
|
||||
? ` (cap ${info.demuxerMaxBytes}MB` +
|
||||
`${info.demuxerMaxBackBytes !== undefined ? ` / ${info.demuxerMaxBackBytes}MB back` : ""}` +
|
||||
@@ -352,7 +357,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.voDriver && (
|
||||
<Text style={textStyle}>
|
||||
VO: {info.voDriver}
|
||||
{t("player.technical_info.vo")} {info.voDriver}
|
||||
{info.hwdec ? ` / ${info.hwdec}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
@@ -364,10 +369,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
)}
|
||||
{info?.droppedFrames !== undefined && info.droppedFrames > 0 && (
|
||||
<Text style={[textStyle, styles.warningText]}>
|
||||
Dropped: {info.droppedFrames} frames
|
||||
{t("player.technical_info.dropped_frames", {
|
||||
count: info.droppedFrames,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{!info && !playMethod && <Text style={textStyle}>Loading...</Text>}
|
||||
{!info && !playMethod && (
|
||||
<Text style={textStyle}>{t("player.technical_info.loading")}</Text>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import {
|
||||
type OptionGroup,
|
||||
@@ -54,6 +55,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
onRatioChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handleRatioSelect = (ratio: AspectRatio) => {
|
||||
@@ -66,7 +68,10 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
{
|
||||
options: ASPECT_RATIO_OPTIONS.map((option) => ({
|
||||
type: "radio" as const,
|
||||
label: option.label,
|
||||
label:
|
||||
option.id === "default"
|
||||
? t("player.aspect_ratio_original")
|
||||
: option.label,
|
||||
value: option.id,
|
||||
selected: option.id === currentRatio,
|
||||
onPress: () => handleRatioSelect(option.id),
|
||||
@@ -94,7 +99,7 @@ export const AspectRatioSelector: React.FC<AspectRatioSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Aspect Ratio'
|
||||
title={t("player.aspect_ratio")}
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
bottomSheetConfig={{
|
||||
|
||||
@@ -23,32 +23,29 @@
|
||||
* - Used to report playback state to Jellyfin server
|
||||
* - Value of -1 means disabled/none
|
||||
*
|
||||
* 2. MPV INDEX (track.mpvIndex)
|
||||
* - MPV's internal track ID
|
||||
* - MPV orders tracks as: [all embedded, then all external]
|
||||
* - IDs: 1..embeddedCount for embedded, embeddedCount+1.. for external
|
||||
* - Value of -1 means track needs replacePlayer() (e.g., burned-in sub)
|
||||
* 2. PLAYER TRACK (selected by IDENTITY, not position)
|
||||
* - Selection resolves the server Index against MPV's REAL track list via
|
||||
* applyMpvSubtitleSelection: externals matched by external-filename,
|
||||
* embedded by language/title. `track.mpvIndex` is no longer used to select
|
||||
* (kept -1) — positional mapping mis-selected when externals/embedded were
|
||||
* reordered or the server hid embedded subs (#954 et al.).
|
||||
*
|
||||
* ============================================================================
|
||||
* SUBTITLE HANDLING
|
||||
* ============================================================================
|
||||
*
|
||||
* Embedded (DeliveryMethod.Embed):
|
||||
* - Already in MPV's track list
|
||||
* - Select via setSubtitleTrack(mpvId)
|
||||
*
|
||||
* External (DeliveryMethod.External):
|
||||
* - Loaded into MPV on video start
|
||||
* - Select via setSubtitleTrack(embeddedCount + externalPosition + 1)
|
||||
* Embedded & External:
|
||||
* - Selected via applyMpvSubtitleSelection (identity match against the live
|
||||
* track list). Menu order matches jellyfin-web (compareTracksForMenu:
|
||||
* embedded first, externals last, forced/default float up).
|
||||
*
|
||||
* Image-based during transcoding:
|
||||
* - Burned into video by Jellyfin, not in MPV
|
||||
* - Requires replacePlayer() to change
|
||||
* - Burned into video by Jellyfin, not in MPV → replacePlayer() to change.
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { File } from "expo-file-system";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
@@ -61,16 +58,18 @@ import {
|
||||
import { Platform } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import type { MpvAudioTrack } from "@/modules";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import {
|
||||
applyMpvSubtitleSelection,
|
||||
compareTracksForMenu,
|
||||
getExternalSubtitleUrl,
|
||||
isImageBasedSubtitle,
|
||||
} from "@/utils/jellyfin/subtitleUtils";
|
||||
import { LOCAL_SUBTITLE_INDEX_START, type Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
|
||||
// Starting index for local (client-downloaded) subtitles
|
||||
// Uses negative indices to avoid collision with Jellyfin indices
|
||||
const LOCAL_SUBTITLE_INDEX_START = -100;
|
||||
|
||||
interface VideoContextProps {
|
||||
subtitleTracks: Track[] | null;
|
||||
audioTracks: Track[] | null;
|
||||
@@ -87,6 +86,7 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
const offline = useOfflineMode();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
@@ -126,10 +126,17 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
audioIndex?: string;
|
||||
subtitleIndex?: string;
|
||||
}) => {
|
||||
// The URL param can hold a local-sub sentinel (selected via setParams) that
|
||||
// only exists in the dying player — the server must get "none" (-1) instead.
|
||||
// NaN (missing/blank param) fails the comparison and passes through as-is.
|
||||
const fallbackSubtitleIndex =
|
||||
Number.parseInt(subtitleIndex, 10) <= LOCAL_SUBTITLE_INDEX_START
|
||||
? "-1"
|
||||
: subtitleIndex;
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId ?? "",
|
||||
audioIndex: params.audioIndex ?? audioIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? subtitleIndex,
|
||||
subtitleIndex: params.subtitleIndex ?? fallbackSubtitleIndex,
|
||||
mediaSourceId: mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue,
|
||||
playbackPosition: playbackPosition,
|
||||
@@ -141,6 +148,19 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
useEffect(() => {
|
||||
if (!tracksReady) return;
|
||||
|
||||
// Guard every state commit against stale runs: api?.basePath /
|
||||
// isCurrentSubImageBased can flip mid-run and restart this effect, and an
|
||||
// earlier async run (which captured an old `api`) must not finish later and
|
||||
// overwrite the fresh track list with callbacks bound to stale closures.
|
||||
// The cleanup flips `cancelled`, so any late commit from a dead run is dropped.
|
||||
let cancelled = false;
|
||||
const commitSubtitleTracks = (next: Track[]) => {
|
||||
if (!cancelled) setSubtitleTracks(next);
|
||||
};
|
||||
const commitAudioTracks = (next: Track[]) => {
|
||||
if (!cancelled) setAudioTracks(next);
|
||||
};
|
||||
|
||||
const fetchTracks = async () => {
|
||||
// Check if this is offline transcoded content
|
||||
// For transcoded offline content, only ONE audio track exists in the file
|
||||
@@ -166,10 +186,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
},
|
||||
];
|
||||
setAudioTracks(audio);
|
||||
commitAudioTracks(audio);
|
||||
} else {
|
||||
// Fallback: show no audio tracks if the stored track wasn't found
|
||||
setAudioTracks([]);
|
||||
commitAudioTracks([]);
|
||||
}
|
||||
|
||||
// For subtitles in transcoded offline content:
|
||||
@@ -179,6 +199,24 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
downloadedItem.userData.subtitleStreamIndex;
|
||||
const subs: Track[] = [];
|
||||
|
||||
// If an IMAGE subtitle was burned into the transcoded download it's in the
|
||||
// video pixels — it can't be turned off or swapped. Show only that entry
|
||||
// instead of advertising "Disable"/text controls that can't affect it.
|
||||
const burnedInSub = allSubs.find(
|
||||
(s) => s.Index === downloadedSubtitleIndex,
|
||||
);
|
||||
if (burnedInSub && isImageBasedSubtitle(burnedInSub)) {
|
||||
commitSubtitleTracks([
|
||||
{
|
||||
name: `${burnedInSub.DisplayTitle || "Unknown"} (burned in)`,
|
||||
index: burnedInSub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {},
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add "Disable" option
|
||||
subs.push({
|
||||
name: "Disable",
|
||||
@@ -190,123 +228,84 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// For text-based subs, they should still be available in the file
|
||||
let subIdx = 1;
|
||||
for (const sub of allSubs) {
|
||||
if (sub.IsTextSubtitleStream) {
|
||||
// Text subs are muxed into the transcoded file and switchable; resolve by
|
||||
// identity against MPV's real track list (same as online). Order matches web.
|
||||
// Image subs aren't in the transcoded file (only the burned one was, handled
|
||||
// above), so skip them here.
|
||||
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||
if (!isImageBasedSubtitle(sub)) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: subIdx,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
playerControls.setSubtitleTrack(subIdx);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
subIdx++;
|
||||
} else if (sub.Index === downloadedSubtitleIndex) {
|
||||
// This image-based sub was burned in - show it but indicate it's active
|
||||
subs.push({
|
||||
name: `${sub.DisplayTitle || "Unknown"} (burned in)`,
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1, // Can't be changed
|
||||
setTrack: () => {
|
||||
// Already burned in, just update params
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
void applyMpvSubtitleSelection(playerControls, {
|
||||
subtitleStreams: allSubs,
|
||||
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||
getExpectedExternalUrl: (s) => {
|
||||
if (!s.DeliveryUrl) return undefined;
|
||||
if (offline) return s.DeliveryUrl;
|
||||
return api?.basePath
|
||||
? `${api.basePath}${s.DeliveryUrl}`
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
commitSubtitleTracks(subs);
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV track handling
|
||||
const audioData = await playerControls.getAudioTracks().catch(() => null);
|
||||
if (cancelled) return;
|
||||
const playerAudio = (audioData as MpvAudioTrack[]) ?? [];
|
||||
|
||||
// Separate embedded vs external subtitles from Jellyfin's list
|
||||
// MPV orders tracks as: [all embedded, then all external]
|
||||
const embeddedSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.Embed,
|
||||
);
|
||||
const externalSubs = allSubs.filter(
|
||||
(s) => s.DeliveryMethod === SubtitleDeliveryMethod.External,
|
||||
);
|
||||
|
||||
// Count embedded subs that will be in MPV
|
||||
// (excludes image-based subs during transcoding as they're burned in)
|
||||
const embeddedInPlayer = embeddedSubs.filter(
|
||||
(s) => !isTranscoding || !isImageBasedSubtitle(s),
|
||||
);
|
||||
|
||||
const subs: Track[] = [];
|
||||
|
||||
// Process all Jellyfin subtitles
|
||||
for (const sub of allSubs) {
|
||||
const isEmbedded = sub.DeliveryMethod === SubtitleDeliveryMethod.Embed;
|
||||
const isExternal =
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
|
||||
// For image-based subs during transcoding, need to refresh player
|
||||
if (isTranscoding && isImageBasedSubtitle(sub)) {
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate MPV track ID based on type
|
||||
// MPV IDs: [1..embeddedCount] for embedded, [embeddedCount+1..] for external
|
||||
let mpvId = -1;
|
||||
|
||||
if (isEmbedded) {
|
||||
// Find position among embedded subs that are in player
|
||||
const embeddedPosition = embeddedInPlayer.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (embeddedPosition !== -1) {
|
||||
mpvId = embeddedPosition + 1; // 1-based ID
|
||||
}
|
||||
} else if (isExternal) {
|
||||
// Find position among external subs, offset by embedded count
|
||||
const externalPosition = externalSubs.findIndex(
|
||||
(s) => s.Index === sub.Index,
|
||||
);
|
||||
if (externalPosition !== -1) {
|
||||
mpvId = embeddedInPlayer.length + externalPosition + 1;
|
||||
}
|
||||
}
|
||||
// Process all Jellyfin subtitles. Selection resolves against MPV's real
|
||||
// track list by identity (applyMpvSubtitleSelection) — never positional
|
||||
// index math, which mis-selects across external/embedded reordering and
|
||||
// server-hidden embedded subs (#954/#1690/#618/#1467/#976/#1451).
|
||||
// Order matches jellyfin-web (embedded first, externals last, forced/default up).
|
||||
for (const sub of [...allSubs].sort(compareTracksForMenu)) {
|
||||
// Image-based subs during transcoding are burned into the video by the
|
||||
// server; both switching TO one and switching AWAY from a currently
|
||||
// active one require a player refresh (re-transcode), not a track change.
|
||||
const needsReplace =
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased);
|
||||
|
||||
subs.push({
|
||||
name: sub.DisplayTitle || "Unknown",
|
||||
index: sub.Index ?? -1,
|
||||
mpvIndex: mpvId,
|
||||
mpvIndex: -1,
|
||||
setTrack: () => {
|
||||
// Transcoding + switching to/from image-based sub
|
||||
if (
|
||||
isTranscoding &&
|
||||
(isImageBasedSubtitle(sub) || isCurrentSubImageBased)
|
||||
) {
|
||||
if (needsReplace) {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct switch in player
|
||||
if (mpvId !== -1) {
|
||||
playerControls.setSubtitleTrack(mpvId);
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback - refresh player
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
router.setParams({ subtitleIndex: String(sub.Index) });
|
||||
void applyMpvSubtitleSelection(playerControls, {
|
||||
subtitleStreams: allSubs,
|
||||
jellyfinSubtitleIndex: sub.Index ?? -1,
|
||||
// Mirror how external subs are loaded into MPV (online: basePath +
|
||||
// DeliveryUrl, offline: local DeliveryUrl) so identity matching by
|
||||
// external-filename lines up.
|
||||
getExpectedExternalUrl: (s) =>
|
||||
getExternalSubtitleUrl(s, { offline, basePath: api?.basePath }),
|
||||
}).then((result) => {
|
||||
// Safety net: a menu-listed sub the player can't select (server-
|
||||
// burned Encode, sidecar never sub-added) only shows up after the
|
||||
// server re-processes the stream with it.
|
||||
if (result.kind === "notFound" || result.kind === "burnedIn") {
|
||||
replacePlayer({ subtitleIndex: String(sub.Index) });
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -374,12 +373,29 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
}
|
||||
|
||||
setSubtitleTracks(subs.sort((a, b) => a.index - b.index));
|
||||
setAudioTracks(audio);
|
||||
// Already in jellyfin-web order (sorted iteration above); "Disable" stays
|
||||
// at the front (unshifted), local downloaded subs at the end.
|
||||
commitSubtitleTracks(subs);
|
||||
commitAudioTracks(audio);
|
||||
};
|
||||
|
||||
fetchTracks();
|
||||
}, [tracksReady, mediaSource, offline, downloadedItem, itemId]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// api?.basePath: setTrack builds external-sub URLs from it — rebuild once the
|
||||
// API is ready so online externals don't resolve with undefined.
|
||||
// isCurrentSubImageBased: setTrack closes over it for the transcode replacePlayer
|
||||
// decision — rebuild when it flips so we refresh the stream when we should.
|
||||
}, [
|
||||
tracksReady,
|
||||
mediaSource,
|
||||
offline,
|
||||
downloadedItem,
|
||||
itemId,
|
||||
api?.basePath,
|
||||
isCurrentSubImageBased,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VideoContext.Provider value={{ subtitleTracks, audioTracks }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ const DropdownView = ({
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
@@ -101,7 +103,7 @@ const DropdownView = ({
|
||||
// Quality Section
|
||||
if (!isOffline) {
|
||||
groups.push({
|
||||
title: "Quality",
|
||||
title: t("player.menu.quality"),
|
||||
options:
|
||||
BITRATES?.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
@@ -116,7 +118,7 @@ const DropdownView = ({
|
||||
// Subtitle Section
|
||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Subtitles",
|
||||
title: t("player.menu.subtitles"),
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
@@ -128,7 +130,7 @@ const DropdownView = ({
|
||||
|
||||
// Subtitle Scale Section
|
||||
groups.push({
|
||||
title: "Subtitle Scale",
|
||||
title: t("player.menu.subtitle_scale"),
|
||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
@@ -142,7 +144,7 @@ const DropdownView = ({
|
||||
// Audio Section
|
||||
if (audioTracks && audioTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Audio",
|
||||
title: t("player.menu.audio"),
|
||||
options: audioTracks.map((track) => ({
|
||||
type: "radio" as const,
|
||||
label: track.name,
|
||||
@@ -156,7 +158,7 @@ const DropdownView = ({
|
||||
// Speed Section
|
||||
if (setPlaybackSpeed) {
|
||||
groups.push({
|
||||
title: "Speed",
|
||||
title: t("player.menu.speed"),
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
@@ -174,8 +176,8 @@ const DropdownView = ({
|
||||
{
|
||||
type: "action" as const,
|
||||
label: showTechnicalInfo
|
||||
? "Hide Technical Info"
|
||||
: "Show Technical Info",
|
||||
? t("player.menu.hide_technical_info")
|
||||
: t("player.menu.show_technical_info"),
|
||||
onPress: onToggleTechnicalInfo,
|
||||
},
|
||||
],
|
||||
@@ -185,6 +187,7 @@ const DropdownView = ({
|
||||
return groups;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
t,
|
||||
isOffline,
|
||||
bitrateValue,
|
||||
changeBitrate,
|
||||
@@ -217,7 +220,7 @@ const DropdownView = ({
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
title='Playback Options'
|
||||
title={t("player.menu.playback_options")}
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user