mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 00:43:08 +01:00
Compare commits
22 Commits
fix/auth-s
...
feat/kefin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21635d6eab | ||
|
|
f101deb8fa | ||
|
|
28749fe042 | ||
|
|
28bf70c0ac | ||
|
|
75f30842f5 | ||
|
|
03b2aec931 | ||
|
|
a39637f187 | ||
|
|
0cac1f8779 | ||
|
|
17a591cd3c | ||
|
|
8f1b8fea31 | ||
|
|
41c2631b35 | ||
|
|
a8698a5c11 | ||
|
|
f2e54cd230 | ||
|
|
14c84f5ec2 | ||
|
|
803ee368ad | ||
|
|
000e873922 | ||
|
|
bc13317f00 | ||
|
|
c024d1ed05 | ||
|
|
c648134954 | ||
|
|
97eec2438b | ||
|
|
1d0c2f0a31 | ||
|
|
eba72e9d73 |
376
.github/workflows/build-apps.yml
vendored
376
.github/workflows/build-apps.yml
vendored
@@ -30,66 +30,64 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- 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: 🗑️ 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: ☕ 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: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# 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
|
||||
|
||||
@@ -132,66 +130,64 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- 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: 🗑️ 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: ☕ 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: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: 📦 Install dependencies and reload submodules
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run submodule-reload
|
||||
|
||||
- name: ☕ Set up JDK 17
|
||||
# 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
|
||||
|
||||
@@ -233,33 +229,19 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- 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: 📥 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 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: 🍞 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
|
||||
@@ -277,6 +259,19 @@ 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
|
||||
@@ -306,26 +301,19 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- 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: 📥 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 Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
- 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
|
||||
@@ -343,6 +331,12 @@ 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
|
||||
@@ -374,33 +368,19 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- 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: 📥 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 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: 🍞 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
|
||||
@@ -418,6 +398,19 @@ 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
|
||||
@@ -445,26 +438,19 @@ jobs:
|
||||
actions: write # dispatch artifact-comment.yml to refresh the PR comment
|
||||
|
||||
steps:
|
||||
- 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: 📥 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 Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.6"
|
||||
- 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
|
||||
@@ -482,6 +468,12 @@ 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:
|
||||
- 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: 📥 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
|
||||
|
||||
- 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,15 +20,14 @@ jobs:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- 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
|
||||
|
||||
104
.github/workflows/linting.yml
vendored
104
.github/workflows/linting.yml
vendored
@@ -2,7 +2,7 @@ name: 🚦 Security & Quality Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
branches: [develop, master]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -11,11 +11,39 @@ 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
|
||||
@@ -39,26 +67,18 @@ jobs:
|
||||
name: 🚑 Expo Doctor Check
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- 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
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: 💾 Cache Bun dependencies
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 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: 📦 Install dependencies (bun)
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -83,26 +103,24 @@ jobs:
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- 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 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
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
# renovate: datasource=node-version depName=node versioning=node
|
||||
node-version: "24.18.0"
|
||||
|
||||
- name: "🍞 Setup Bun"
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
# renovate: datasource=npm depName=bun
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: "📦 Install dependencies"
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
81
.github/workflows/pr-title-comment.yml
vendored
81
.github/workflows/pr-title-comment.yml
vendored
@@ -1,81 +0,0 @@
|
||||
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
42
.github/workflows/pr-title.yml
vendored
@@ -1,42 +0,0 @@
|
||||
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,8 +13,6 @@ 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:
|
||||
@@ -65,26 +63,18 @@ jobs:
|
||||
artifact_name: streamyfin-android-tv-apk
|
||||
|
||||
steps:
|
||||
- parallel:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
show-progress: false
|
||||
- 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 EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
eas-cache: true
|
||||
- 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
|
||||
@@ -99,6 +89,13 @@ 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,20 +25,19 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- 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: 📥 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
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, RefreshControl, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
|
||||
import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function FavoritesPage() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const refetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await invalidateCache();
|
||||
@@ -20,6 +30,8 @@ export default function FavoritesPage() {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -34,7 +46,26 @@ export default function FavoritesPage() {
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
<Favorites />
|
||||
{watchlistEnabled && (
|
||||
<View className='pl-4 pr-4 flex flex-row mb-2'>
|
||||
<FavoritesTabButtons
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isWatchlist ? (
|
||||
<Favorites
|
||||
filter='Likes'
|
||||
queryKeyBase='watchlist'
|
||||
seeAllNamespace='kefintweaksWatchlist'
|
||||
emptyTitleKey='kefintweaksWatchlist.noDataTitle'
|
||||
emptyTextKey='kefintweaksWatchlist.noData'
|
||||
/>
|
||||
) : (
|
||||
<Favorites />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
@@ -10,7 +11,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useWindowDimensions, View } from "react-native";
|
||||
import { Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -52,9 +53,13 @@ export default function FavoritesSeeAllScreen() {
|
||||
const searchParams = useLocalSearchParams<{
|
||||
type?: string;
|
||||
title?: string;
|
||||
filter?: string;
|
||||
}>();
|
||||
const typeParam = searchParams.type;
|
||||
const titleParam = searchParams.title;
|
||||
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
|
||||
const filter: ItemFilter =
|
||||
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
if (!isFavoriteType(typeParam)) return null;
|
||||
@@ -77,7 +82,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -90,12 +95,12 @@ export default function FavoritesSeeAllScreen() {
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id],
|
||||
[api, itemType, user?.Id, filter],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
@@ -155,7 +160,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: true,
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -50,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||
export default function SettingsTV() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -877,6 +877,15 @@ export default function SettingsTV() {
|
||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||
/>
|
||||
|
||||
{/* Plugins Section */}
|
||||
<TVSectionHeader title={t("home.settings.plugins.plugins_title")} />
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
|
||||
value={settings.useKefinTweaks}
|
||||
onToggle={(value) => updateSettings({ useKefinTweaks: value })}
|
||||
disabled={pluginSettings?.useKefinTweaks?.locked === true}
|
||||
/>
|
||||
|
||||
{/* Storage Section */}
|
||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||
<TVSettingsOptionButton
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
|
||||
import { DownloadItems } from "@/components/DownloadItem";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { NextUp } from "@/components/series/NextUp";
|
||||
@@ -18,6 +19,7 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
buildOfflineSeriesFromEpisodes,
|
||||
getDownloadedEpisodesForSeries,
|
||||
@@ -30,6 +32,7 @@ import { storage } from "@/utils/mmkv";
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
id: seriesId,
|
||||
@@ -137,6 +140,7 @@ const page: React.FC = () => {
|
||||
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
|
||||
{!Platform.isTV && (
|
||||
<DownloadItems
|
||||
size='large'
|
||||
@@ -157,7 +161,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
@@ -50,7 +50,9 @@ function TVTabLayout() {
|
||||
const router = useRouter();
|
||||
|
||||
const currentTab = segments.find(isTabRoute);
|
||||
const lastSegment = segments[segments.length - 1] ?? "";
|
||||
// Widened to string: typed routes omit "index" segments, but they can still
|
||||
// appear at runtime, so keep the guard without tripping TS2367.
|
||||
const lastSegment: string = segments[segments.length - 1] ?? "";
|
||||
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
|
||||
|
||||
const tabs: TVNavBarTab[] = useMemo(
|
||||
|
||||
@@ -893,27 +893,6 @@ export default function DirectPlayerPage() {
|
||||
// Check if we're transcoding
|
||||
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
|
||||
|
||||
// A transcoded stream only carries the audio track the server encoded
|
||||
// into it — switching requires re-negotiating the stream with the new
|
||||
// index (like the mobile menu's replacePlayer), not an mpv aid change.
|
||||
if (isTranscoding) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item?.Id ?? "",
|
||||
audioIndex: String(index),
|
||||
subtitleIndex: String(currentSubtitleIndex),
|
||||
mediaSourceId: stream?.mediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString() ?? "",
|
||||
playbackPosition: msToTicks(progress.get()).toString(),
|
||||
}).toString();
|
||||
// Destroy the current mpv instance BEFORE navigating, same rationale as
|
||||
// goToNextItem/goToPreviousItem: Expo Router briefly holds two players
|
||||
// during the transition, and two simultaneous decoders OOM-kill low-RAM
|
||||
// devices. Resume is preserved via the playbackPosition param.
|
||||
videoRef.current?.destroy().catch(() => {});
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Jellyfin index to MPV track ID
|
||||
const mpvTrackId = getMpvAudioId(
|
||||
stream?.mediaSource,
|
||||
@@ -925,14 +904,7 @@ export default function DirectPlayerPage() {
|
||||
await videoRef.current?.setAudioTrack?.(mpvTrackId);
|
||||
}
|
||||
},
|
||||
[
|
||||
stream?.mediaSource,
|
||||
item?.Id,
|
||||
currentSubtitleIndex,
|
||||
bitrateValue,
|
||||
router,
|
||||
progress,
|
||||
],
|
||||
[stream?.mediaSource],
|
||||
);
|
||||
|
||||
// TV subtitle track change handler
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
@@ -76,20 +75,6 @@ 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();
|
||||
|
||||
@@ -11,7 +11,6 @@ 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";
|
||||
@@ -548,7 +547,6 @@ function Layout() {
|
||||
closeButton
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
{!Platform.isTV && <PendingAccountSaveModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -109,7 +109,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.20.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
@@ -1015,7 +1015,7 @@
|
||||
|
||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||
|
||||
"expo-doctor": ["expo-doctor@1.20.0", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-YL0uGQGZ65tWOCOiDH6BFzBkx/9N46MyBUdRfa4t6iuG4tbLg/phRrhmuaNm99QJVMqYtt3ksRbFCzuYOuX20Q=="],
|
||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@56.0.8", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ=="],
|
||||
|
||||
|
||||
29
components/AddToKefinWatchlist.tsx
Normal file
29
components/AddToKefinWatchlist.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist toggle, backed by Jellyfin's "Likes" rating.
|
||||
* Render only when settings.useKefinTweaks is enabled.
|
||||
*/
|
||||
export const AddToKefinWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||
const { isWatchlisted, toggleWatchlist, isPending } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
color={isWatchlisted ? "purple" : "white"}
|
||||
onPress={toggleWatchlist}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
@@ -138,6 +139,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.useKefinTweaks && (
|
||||
<AddToKefinWatchlist item={item} />
|
||||
)}
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
@@ -160,6 +164,9 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.useKefinTweaks && (
|
||||
<AddToKefinWatchlist item={item} />
|
||||
)}
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
@@ -178,6 +185,7 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
settings.useKefinTweaks,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
TVRefreshButton,
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
TVWatchlistButton,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -752,6 +753,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
</Text>
|
||||
</TVButton>
|
||||
<TVFavoriteButton item={item} />
|
||||
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
|
||||
<TVPlayedButton item={item} />
|
||||
<TVRefreshButton itemId={item.Id} />
|
||||
</View>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import {
|
||||
pendingAccountSaveAtom,
|
||||
useJellyfin,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// 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) => console.warn("Failed to save account:", error),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
|
||||
fillColor?: "primary";
|
||||
color?: "white" | "purple";
|
||||
hapticFeedback?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -24,6 +25,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
fillColor,
|
||||
color = "white",
|
||||
hapticFeedback = true,
|
||||
disabled = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
|
||||
@@ -31,6 +33,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const handlePress = () => {
|
||||
if (disabled) return;
|
||||
if (hapticFeedback) {
|
||||
lightHapticFeedback();
|
||||
}
|
||||
@@ -41,7 +44,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
disabled={disabled}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
@@ -60,7 +64,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
disabled={disabled}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
@@ -78,7 +83,8 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
disabled={disabled}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
@@ -96,9 +102,10 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-transparent"
|
||||
}`}
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
@@ -113,10 +120,14 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} {...(viewProps as any)}>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
{icon ? (
|
||||
|
||||
99
components/TrackSheet.tsx
Normal file
99
components/TrackSheet.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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,119 +1,43 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { Platform, View, type ViewStyle } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
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={[
|
||||
tvBadgeBase,
|
||||
{ minWidth: scaleSize(28), paddingHorizontal: scaleSize(7) },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: scaleSize(15),
|
||||
fontWeight: "700",
|
||||
color: "black",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[mobileBadgeBase, { minWidth: 20, paddingHorizontal: 5 }]}>
|
||||
<Text style={{ fontSize: 12, fontWeight: "700", color: "white" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
import type React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
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)) {
|
||||
// TV: Show white checkmark when watched
|
||||
if (
|
||||
item.UserData?.Played &&
|
||||
(item.Type === "Movie" || item.Type === "Episode")
|
||||
) {
|
||||
return (
|
||||
<View style={[tvBadgeBase, { width: scaleSize(28) }]}>
|
||||
<Ionicons name='checkmark' size={scaleSize(18)} color='black' />
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='checkmark' size={18} color='black' />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Series/BoxSet with remaining episodes → count badge
|
||||
return <UnplayedCountBadge item={item} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mobile: purple corner ribbon for unwatched Movie/Episode (existing behavior)
|
||||
// Mobile: Show purple triangle for unwatched
|
||||
return (
|
||||
<>
|
||||
{/* 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} />
|
||||
{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' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -155,6 +157,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
@@ -183,36 +187,66 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
return;
|
||||
|
||||
const options: string[] = [
|
||||
t("common.mark_as_played"),
|
||||
t("common.mark_as_not_played"),
|
||||
isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
||||
t("common.cancel"),
|
||||
// Build options as { label, action } so dynamic entries (watchlist,
|
||||
// offline delete) don't break index-based handling.
|
||||
const actions: {
|
||||
label: string;
|
||||
action: () => void;
|
||||
destructive?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
label: t("common.mark_as_played"),
|
||||
action: () => {
|
||||
markAsPlayedStatus(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("common.mark_as_not_played"),
|
||||
action: () => {
|
||||
markAsPlayedStatus(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites"),
|
||||
action: toggleFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
if (settings?.useKefinTweaks) {
|
||||
actions.push({
|
||||
label: isWatchlisted
|
||||
? t("watchlists.remove_from_watchlist")
|
||||
: t("watchlists.add_to_watchlist"),
|
||||
action: toggleWatchlist,
|
||||
});
|
||||
}
|
||||
|
||||
if (isOffline && item.Id) {
|
||||
const id = item.Id;
|
||||
actions.push({
|
||||
label: t("home.downloads.delete_download"),
|
||||
action: () => deleteFile(id),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
const options = [...actions.map((a) => a.label), t("common.cancel")];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
const destructiveButtonIndex = actions.findIndex((a) => a.destructive);
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
destructiveButtonIndex:
|
||||
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
await markAsPlayedStatus(true);
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||
return;
|
||||
actions[selectedIndex].action();
|
||||
},
|
||||
);
|
||||
}, [
|
||||
@@ -220,6 +254,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
settings?.useKefinTweaks,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
|
||||
74
components/favorites/FavoritesTabButtons.tsx
Normal file
74
components/favorites/FavoritesTabButtons.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import crashes the route tree on tvOS at module load.
|
||||
// Load it lazily and only off-TV; TV never renders this component.
|
||||
const { Button, Host, HStack, Spacer } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { buttonStyle } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
|
||||
type ViewType = "Favorites" | "Watchlist";
|
||||
|
||||
interface FavoritesTabButtonsProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export const FavoritesTabButtons: React.FC<FavoritesTabButtonsProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<Host style={{ height: 40, flex: 1 }}>
|
||||
<HStack spacing={8}>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
viewType === "Favorites" ? "glassProminent" : "glass",
|
||||
),
|
||||
]}
|
||||
onPress={() => setViewType("Favorites")}
|
||||
label={t("tabs.favorites")}
|
||||
/>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
viewType === "Watchlist" ? "glassProminent" : "glass",
|
||||
),
|
||||
]}
|
||||
onPress={() => setViewType("Watchlist")}
|
||||
label={t("favorites.watchlist")}
|
||||
/>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
// Android UI
|
||||
return (
|
||||
<View className='flex flex-row gap-1 mr-1'>
|
||||
<TouchableOpacity onPress={() => setViewType("Favorites")}>
|
||||
<Tag
|
||||
text={t("tabs.favorites")}
|
||||
textClass='p-1'
|
||||
className={viewType === "Favorites" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setViewType("Watchlist")}>
|
||||
<Tag
|
||||
text={t("favorites.watchlist")}
|
||||
textClass='p-1'
|
||||
className={viewType === "Watchlist" ? "bg-purple-600" : undefined}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
117
components/favorites/TVFavoritesTabBadges.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
type ViewType = "Favorites" | "Watchlist";
|
||||
|
||||
interface TVFavoritesTabBadgeProps {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
const TVFavoritesTabBadge: React.FC<TVFavoritesTabBadgeProps> = ({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ duration: 150 });
|
||||
|
||||
// Design language: white for focused/selected, transparent white for unfocused
|
||||
const getBackgroundColor = () => {
|
||||
if (focused) return "#fff";
|
||||
if (isSelected) return "rgba(255,255,255,0.25)";
|
||||
return "rgba(255,255,255,0.1)";
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (focused) return "#000";
|
||||
return "#fff";
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: getTextColor(),
|
||||
fontWeight: isSelected || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVFavoritesTabBadgesProps {
|
||||
viewType: ViewType;
|
||||
setViewType: (type: ViewType) => void;
|
||||
/** Only render the toggle when the KefinTweaks watchlist is enabled. */
|
||||
enabled: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
}
|
||||
|
||||
export const TVFavoritesTabBadges: React.FC<TVFavoritesTabBadgesProps> = ({
|
||||
viewType,
|
||||
setViewType,
|
||||
enabled,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TVFavoritesTabBadge
|
||||
label={t("tabs.favorites")}
|
||||
isSelected={viewType === "Favorites"}
|
||||
onPress={() => setViewType("Favorites")}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Favorites"}
|
||||
/>
|
||||
<TVFavoritesTabBadge
|
||||
label={t("favorites.watchlist")}
|
||||
isSelected={viewType === "Watchlist"}
|
||||
onPress={() => setViewType("Watchlist")}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && viewType === "Watchlist"}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
// PNG ASSET
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
@@ -20,20 +23,39 @@ type FavoriteTypes =
|
||||
| "Video"
|
||||
| "BoxSet"
|
||||
| "Playlist";
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
// `null` = not settled yet (loading/unknown); avoids flashing the empty
|
||||
// message during a favorites/watchlist switch before the new queries resolve.
|
||||
type EmptyState = Record<FavoriteTypes, boolean | null>;
|
||||
|
||||
export const Favorites = () => {
|
||||
interface FavoritesProps {
|
||||
/** Jellyfin item filter. "IsFavorite" (default) or "Likes" for the watchlist view. */
|
||||
filter?: ItemFilter;
|
||||
/** Query key segment used to keep favorites/watchlist caches separate. */
|
||||
queryKeyBase?: string;
|
||||
emptyTitleKey?: string;
|
||||
emptyTextKey?: string;
|
||||
/** Namespace for the see-all page headers ("favorites" or "kefintweaksWatchlist"). */
|
||||
seeAllNamespace?: "kefintweaksWatchlist" | "favorites";
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
filter = "IsFavorite",
|
||||
queryKeyBase = "favorites",
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
seeAllNamespace = "favorites",
|
||||
}: FavoritesProps = {}) => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const pageSize = 20;
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
Episode: false,
|
||||
Video: false,
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
Series: null,
|
||||
Movie: null,
|
||||
Episode: null,
|
||||
Video: null,
|
||||
BoxSet: null,
|
||||
Playlist: null,
|
||||
});
|
||||
|
||||
const fetchFavoritesByType = useCallback(
|
||||
@@ -46,7 +68,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -56,39 +78,28 @@ export const Favorites = () => {
|
||||
limit: limit,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
const items = response.data.Items || [];
|
||||
|
||||
// Update empty state for this specific type only for the first page
|
||||
if (startIndex === 0) {
|
||||
setEmptyState((prev) => ({
|
||||
...prev,
|
||||
[itemType as FavoriteTypes]: items.length === 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
// Reset empty state when component mounts or dependencies change
|
||||
useEffect(() => {
|
||||
setEmptyState({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
Episode: false,
|
||||
Video: false,
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user]);
|
||||
// Emptiness is reported by each list once its query settles (incl. cache
|
||||
// hits), so it stays correct where a queryFn side effect would go stale.
|
||||
const setTypeEmpty = useCallback(
|
||||
(type: FavoriteTypes, isEmpty: boolean | null) =>
|
||||
setEmptyState((prev) =>
|
||||
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Check if all categories that have been loaded are empty
|
||||
// Show the empty message only once every category has settled AND is empty.
|
||||
// A `null` (still loading) keeps it hidden, so switching favorites/watchlist
|
||||
// (props swap in place, no remount) never flashes a stale empty state.
|
||||
const areAllEmpty = () => {
|
||||
const loadedCategories = Object.values(emptyState);
|
||||
const categories = Object.values(emptyState);
|
||||
return (
|
||||
loadedCategories.length > 0 &&
|
||||
loadedCategories.every((isEmpty) => isEmpty)
|
||||
categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,47 +134,26 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
// Navigate to the shared see-all screen. `name` is the capitalized type
|
||||
// suffix of the see-all header key (e.g. "Series" -> "seeAllSeries").
|
||||
// The namespace is branched explicitly so each t() call has a static prefix
|
||||
// (favorites.seeAll* / kefintweaksWatchlist.seeAll*) that the i18n usage
|
||||
// checker can detect — see scripts/check-i18n-keys.mjs. The `as any` is
|
||||
// needed because the route's custom params aren't part of expo-router's
|
||||
// typed Href.
|
||||
const seeAll = useCallback(
|
||||
(type: FavoriteTypes, name: string) => {
|
||||
const title =
|
||||
seeAllNamespace === "kefintweaksWatchlist"
|
||||
? t(`kefintweaksWatchlist.seeAll${name}`)
|
||||
: t(`favorites.seeAll${name}`);
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type, title, filter },
|
||||
} as any);
|
||||
},
|
||||
[router, filter, seeAllNamespace],
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
@@ -176,61 +166,67 @@ export const Favorites = () => {
|
||||
source={heart}
|
||||
/>
|
||||
<Text className='text-xl font-semibold text-white mb-2'>
|
||||
{t("favorites.noDataTitle")}
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||
{t("favorites.noData")}
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllSeries}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
|
||||
onPressSeeAll={() => seeAll("Series", "Series")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllMovies}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
|
||||
onPressSeeAll={() => seeAll("Movie", "Movies")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllEpisodes}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
|
||||
onPressSeeAll={() => seeAll("Episode", "Episodes")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllVideos}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
|
||||
onPressSeeAll={() => seeAll("Video", "Videos")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllBoxsets}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
|
||||
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllPlaylists}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
|
||||
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
@@ -25,7 +30,9 @@ type FavoriteTypes =
|
||||
| "Video"
|
||||
| "BoxSet"
|
||||
| "Playlist";
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
// `null` = not settled yet (loading/unknown); avoids flashing the empty
|
||||
// message during a favorites/watchlist switch before the new queries resolve.
|
||||
type EmptyState = Record<FavoriteTypes, boolean | null>;
|
||||
|
||||
export const Favorites = () => {
|
||||
const typography = useScaledTVTypography();
|
||||
@@ -33,14 +40,34 @@ export const Favorites = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const pageSize = 20;
|
||||
|
||||
// KefinTweaks watchlist (Likes-backed) view, toggled in-place like Discover.
|
||||
const watchlistEnabled = settings?.useKefinTweaks ?? false;
|
||||
const [viewType, setViewType] = useState<"Favorites" | "Watchlist">(
|
||||
"Favorites",
|
||||
);
|
||||
const filter: ItemFilter =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "Likes" : "IsFavorite";
|
||||
const queryKeyBase =
|
||||
watchlistEnabled && viewType === "Watchlist" ? "watchlist" : "favorites";
|
||||
// Translation namespace for the empty state, swapped for the KefinTweaks
|
||||
// watchlist (Likes-backed) view. Section titles stay generic ("Series").
|
||||
const emptyNamespace =
|
||||
watchlistEnabled && viewType === "Watchlist"
|
||||
? "kefintweaksWatchlist"
|
||||
: "favorites";
|
||||
const emptyTitleKey = `${emptyNamespace}.noDataTitle`;
|
||||
const emptyTextKey = `${emptyNamespace}.noData`;
|
||||
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
Episode: false,
|
||||
Video: false,
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
Series: null,
|
||||
Movie: null,
|
||||
Episode: null,
|
||||
Video: null,
|
||||
BoxSet: null,
|
||||
Playlist: null,
|
||||
});
|
||||
|
||||
const fetchFavoritesByType = useCallback(
|
||||
@@ -53,7 +80,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: ["IsFavorite"],
|
||||
filters: [filter],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -63,36 +90,28 @@ export const Favorites = () => {
|
||||
limit: limit,
|
||||
includeItemTypes: [itemType],
|
||||
});
|
||||
const items = response.data.Items || [];
|
||||
|
||||
if (startIndex === 0) {
|
||||
setEmptyState((prev) => ({
|
||||
...prev,
|
||||
[itemType as FavoriteTypes]: items.length === 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return items;
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, user],
|
||||
[api, user, filter],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEmptyState({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
Episode: false,
|
||||
Video: false,
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user]);
|
||||
// Emptiness is reported by each list once its query settles (incl. cache
|
||||
// hits), so it stays correct where a queryFn side effect would go stale.
|
||||
const setTypeEmpty = useCallback(
|
||||
(type: FavoriteTypes, isEmpty: boolean | null) =>
|
||||
setEmptyState((prev) =>
|
||||
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty },
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Show the empty message only once every category has settled AND is empty.
|
||||
// A `null` (still loading) keeps it hidden, so switching favorites/watchlist
|
||||
// never flashes a stale empty state.
|
||||
const areAllEmpty = () => {
|
||||
const loadedCategories = Object.values(emptyState);
|
||||
const categories = Object.values(emptyState);
|
||||
return (
|
||||
loadedCategories.length > 0 &&
|
||||
loadedCategories.every((isEmpty) => isEmpty)
|
||||
categories.length > 0 && categories.every((isEmpty) => isEmpty === true)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -127,49 +146,14 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
if (areAllEmpty()) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const tabBadges = (
|
||||
<TVFavoritesTabBadges
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
enabled={watchlistEnabled}
|
||||
hasTVPreferredFocus={watchlistEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -178,52 +162,108 @@ export const Favorites = () => {
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<View style={{ gap: SECTION_GAP, flex: 1 }}>
|
||||
{watchlistEnabled && (
|
||||
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||
{tabBadges}
|
||||
</View>
|
||||
)}
|
||||
{/* Rendered alongside the lists (never instead of them) so they stay
|
||||
mounted and re-report emptiness on a favorites/watchlist switch;
|
||||
an early return here would freeze the all-empty state. */}
|
||||
{areAllEmpty() && (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTitleKey)}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t(emptyTextKey)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
isFirstSection
|
||||
isFirstSection={!watchlistEnabled}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
pageSize={pageSize}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -39,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||
import {
|
||||
apiAtom,
|
||||
pendingAccountSaveAtom,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
@@ -93,9 +89,6 @@ 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).
|
||||
@@ -104,9 +97,7 @@ const HomeMobile = () => {
|
||||
// Show intro modal on first launch
|
||||
useEffect(() => {
|
||||
const hasShownIntro = storage.getBoolean("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) {
|
||||
if (!hasShownIntro) {
|
||||
const timer = setTimeout(() => {
|
||||
showIntro();
|
||||
}, 1000);
|
||||
@@ -115,7 +106,7 @@ const HomeMobile = () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [showIntro, pendingAccountSave]);
|
||||
}, [showIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
|
||||
@@ -32,6 +32,13 @@ interface Props extends ViewProps {
|
||||
onPressSeeAll?: () => void;
|
||||
enabled?: boolean;
|
||||
onLoaded?: () => void;
|
||||
/**
|
||||
* Reports emptiness whenever the query settles (incl. cache hits):
|
||||
* `null` while loading (unknown), otherwise whether the list is empty.
|
||||
* Lets a parent derive an aggregate empty-state reactively instead of via a
|
||||
* queryFn side effect, which React Query skips when it serves cache.
|
||||
*/
|
||||
onEmptyStateChange?: (isEmpty: boolean | null) => void;
|
||||
}
|
||||
|
||||
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -45,6 +52,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
onPressSeeAll,
|
||||
enabled = true,
|
||||
onLoaded,
|
||||
onEmptyStateChange,
|
||||
...props
|
||||
}) => {
|
||||
const effectivePageSize = Math.max(1, pageSize);
|
||||
@@ -52,6 +60,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
@@ -103,6 +112,17 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
return deduped;
|
||||
}, [data]);
|
||||
|
||||
// Report emptiness on every settle (incl. cache hits). Errors report null
|
||||
// (unknown) so a failed fetch never reads as "no content". Callback held in
|
||||
// a ref so an inline parent callback doesn't retrigger the effect each render.
|
||||
const onEmptyStateChangeRef = useRef(onEmptyStateChange);
|
||||
onEmptyStateChangeRef.current = onEmptyStateChange;
|
||||
useEffect(() => {
|
||||
onEmptyStateChangeRef.current?.(
|
||||
isLoading || isError ? null : allItems.length === 0,
|
||||
);
|
||||
}, [isLoading, isError, allItems.length]);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||
return allItems.map((_, index) => index * itemWidth);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -42,6 +42,13 @@ interface Props extends ViewProps {
|
||||
isFirstSection?: boolean;
|
||||
onItemFocus?: (item: BaseItemDto) => void;
|
||||
parentId?: string;
|
||||
/**
|
||||
* Reports emptiness whenever the query settles (incl. cache hits):
|
||||
* `null` while loading (unknown), otherwise whether the list is empty.
|
||||
* Lets a parent derive an aggregate empty-state reactively instead of via a
|
||||
* queryFn side effect, which React Query skips when it serves cache.
|
||||
*/
|
||||
onEmptyStateChange?: (isEmpty: boolean | null) => void;
|
||||
}
|
||||
|
||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||
@@ -123,6 +130,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
isFirstSection = false,
|
||||
onItemFocus,
|
||||
parentId,
|
||||
onEmptyStateChange,
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
@@ -145,24 +153,30 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
[onItemFocus],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.length < effectivePageSize) {
|
||||
return undefined;
|
||||
}
|
||||
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
if (lastPage.length < effectivePageSize) {
|
||||
return undefined;
|
||||
}
|
||||
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -182,6 +196,17 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
return deduped;
|
||||
}, [data]);
|
||||
|
||||
// Report emptiness on every settle (incl. cache hits). Errors report null
|
||||
// (unknown) so a failed fetch never reads as "no content". Callback held in
|
||||
// a ref so an inline parent callback doesn't retrigger the effect each render.
|
||||
const onEmptyStateChangeRef = useRef(onEmptyStateChange);
|
||||
onEmptyStateChangeRef.current = onEmptyStateChange;
|
||||
useEffect(() => {
|
||||
onEmptyStateChangeRef.current?.(
|
||||
isLoading || isError ? null : allItems.length === 0,
|
||||
);
|
||||
}, [isLoading, isError, allItems.length]);
|
||||
|
||||
const itemWidth =
|
||||
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;
|
||||
|
||||
|
||||
@@ -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, useSetAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -21,14 +21,13 @@ import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
apiAtom,
|
||||
pendingAccountSaveAtom,
|
||||
useJellyfin,
|
||||
userAtom,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import type { SavedServer } from "@/utils/secureCredentials";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import type {
|
||||
AccountSecurityType,
|
||||
SavedServer,
|
||||
} from "@/utils/secureCredentials";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
@@ -36,7 +35,6 @@ const CredentialsSchema = z.object({
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
@@ -47,7 +45,6 @@ export const Login: React.FC = () => {
|
||||
loginWithSavedCredential,
|
||||
loginWithPassword,
|
||||
} = useJellyfin();
|
||||
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
|
||||
|
||||
const {
|
||||
apiUrl: _apiUrl,
|
||||
@@ -67,24 +64,13 @@ export const Login: React.FC = () => {
|
||||
password: _password || "",
|
||||
});
|
||||
|
||||
// Save account state — only the intent lives here; the protection picker is
|
||||
// the global PendingAccountSaveModal, shown after the login succeeds.
|
||||
// Save account state
|
||||
const [saveAccount, setSaveAccount] = useState(false);
|
||||
|
||||
// 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]);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [pendingLogin, setPendingLogin] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
// Handle URL params for server connection
|
||||
useEffect(() => {
|
||||
@@ -131,22 +117,29 @@ export const Login: React.FC = () => {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (!result.success) return;
|
||||
|
||||
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 });
|
||||
if (saveAccount) {
|
||||
setPendingLogin({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
});
|
||||
setShowSaveModal(true);
|
||||
} else {
|
||||
await performLogin(credentials.username, credentials.password);
|
||||
}
|
||||
};
|
||||
|
||||
const performLogin = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<boolean> => {
|
||||
options?: {
|
||||
saveAccount?: boolean;
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
},
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password, serverName);
|
||||
return true;
|
||||
await login(username, password, serverName, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
Alert.alert(t("login.connection_failed"), error.message);
|
||||
@@ -156,9 +149,23 @@ export const Login: React.FC = () => {
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,7 +259,6 @@ export const Login: React.FC = () => {
|
||||
try {
|
||||
const code = await initiateQuickConnect();
|
||||
if (code) {
|
||||
setQuickConnectActive(true);
|
||||
Alert.alert(
|
||||
t("login.quick_connect"),
|
||||
t("login.enter_code_to_login", { code: code }),
|
||||
@@ -437,6 +443,16 @@ export const Login: React.FC = () => {
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<SaveAccountModal
|
||||
visible={showSaveModal}
|
||||
onClose={() => {
|
||||
setShowSaveModal(false);
|
||||
setPendingLogin(null);
|
||||
}}
|
||||
onSave={handleSaveAccountConfirm}
|
||||
username={pendingLogin?.username || credentials.username}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,7 +3,6 @@ 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";
|
||||
|
||||
@@ -53,7 +52,6 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
} from "react-native";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
UnplayedCountBadge,
|
||||
WatchedIndicator,
|
||||
} from "@/components/WatchedIndicator";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
@@ -430,12 +427,6 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -468,7 +459,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
|
||||
/>
|
||||
{PlayButtonOverlay}
|
||||
{NowPlayingBadge}
|
||||
{showWatchedIndicator && <WatchedIndicator item={item} />}
|
||||
<WatchedIndicator item={item} />
|
||||
<ProgressBar item={item} />
|
||||
</View>
|
||||
);
|
||||
|
||||
36
components/tv/TVWatchlistButton.tsx
Normal file
36
components/tv/TVWatchlistButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import React from "react";
|
||||
import { useWatchlist } from "@/hooks/useWatchlist";
|
||||
import { TVButton } from "./TVButton";
|
||||
|
||||
export interface TVWatchlistButtonProps {
|
||||
item: BaseItemDto;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist toggle (Likes-backed) for TV detail pages.
|
||||
* Render only when settings.useKefinTweaks is enabled.
|
||||
*/
|
||||
export const TVWatchlistButton: React.FC<TVWatchlistButtonProps> = ({
|
||||
item,
|
||||
disabled,
|
||||
}) => {
|
||||
const { isWatchlisted, toggleWatchlist, isPending } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<TVButton
|
||||
onPress={toggleWatchlist}
|
||||
variant='glass'
|
||||
square
|
||||
disabled={disabled || isPending}
|
||||
>
|
||||
<Ionicons
|
||||
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
size={28}
|
||||
color='#FFFFFF'
|
||||
/>
|
||||
</TVButton>
|
||||
);
|
||||
};
|
||||
@@ -70,3 +70,5 @@ export { TVTrackCard } from "./TVTrackCard";
|
||||
// User switching
|
||||
export type { TVUserCardProps } from "./TVUserCard";
|
||||
export { TVUserCard } from "./TVUserCard";
|
||||
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||
|
||||
@@ -564,9 +564,6 @@ 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]);
|
||||
|
||||
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
560
components/video-player/controls/TVSubtitleSheet.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -12,7 +12,6 @@ interface ShowOptionsParams<T> {
|
||||
onSelect: (value: T) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVOptionModal = () => {
|
||||
@@ -27,7 +26,6 @@ export const useTVOptionModal = () => {
|
||||
onSelect: params.onSelect,
|
||||
cardWidth: params.cardWidth,
|
||||
cardHeight: params.cardHeight,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-option-modal");
|
||||
},
|
||||
|
||||
156
hooks/useWatchlist.ts
Normal file
156
hooks/useWatchlist.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Shared atom to store watchlist (Likes) status across all components
|
||||
// Maps itemId -> isWatchlisted
|
||||
const watchlistAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
/**
|
||||
* KefinTweaks watchlist is backed by Jellyfin's native "Likes" rating.
|
||||
* Toggling watchlist membership toggles UserData.Likes on the item.
|
||||
*/
|
||||
export const useWatchlist = (item: BaseItemDto) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [watchlist, setWatchlist] = useAtom(watchlistAtom);
|
||||
|
||||
const watchlistKey = user?.Id && item.Id ? `${user.Id}:${item.Id}` : "";
|
||||
|
||||
// Get current watchlist status from shared state, falling back to item data
|
||||
const isWatchlisted = watchlistKey
|
||||
? (watchlist[watchlistKey] ?? item.UserData?.Likes)
|
||||
: item.UserData?.Likes;
|
||||
|
||||
// Update shared state when item data changes
|
||||
useEffect(() => {
|
||||
if (watchlistKey && item.UserData?.Likes !== undefined) {
|
||||
setWatchlist((prev) => ({
|
||||
...prev,
|
||||
[watchlistKey]: item.UserData!.Likes!,
|
||||
}));
|
||||
}
|
||||
}, [watchlistKey, item.UserData?.Likes, setWatchlist]);
|
||||
// Helper to update watchlist status in shared state
|
||||
const setIsWatchlisted = useCallback(
|
||||
(value: boolean | null | undefined) => {
|
||||
if (watchlistKey && typeof value === "boolean") {
|
||||
setWatchlist((prev) => ({ ...prev, [watchlistKey]: value }));
|
||||
}
|
||||
},
|
||||
[watchlistKey, setWatchlist],
|
||||
);
|
||||
|
||||
// Use refs to avoid stale closure issues in mutationFn
|
||||
const itemRef = useRef(item);
|
||||
const apiRef = useRef(api);
|
||||
const userRef = useRef(user);
|
||||
|
||||
// Keep refs updated
|
||||
useEffect(() => {
|
||||
itemRef.current = item;
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
apiRef.current = api;
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const itemQueryKeyPrefix = useMemo(
|
||||
() => ["item", item.Id] as const,
|
||||
[item.Id],
|
||||
);
|
||||
|
||||
const updateItemInQueries = useCallback(
|
||||
(newData: Partial<BaseItemDto>) => {
|
||||
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||
{ queryKey: itemQueryKeyPrefix },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
...newData,
|
||||
UserData: { ...old.UserData, ...newData.UserData },
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
[itemQueryKeyPrefix, queryClient],
|
||||
);
|
||||
|
||||
const watchlistMutation = useMutation({
|
||||
mutationFn: async (nextIsWatchlisted: boolean) => {
|
||||
const currentApi = apiRef.current;
|
||||
const currentUser = userRef.current;
|
||||
const currentItem = itemRef.current;
|
||||
|
||||
if (!currentApi || !currentUser?.Id || !currentItem?.Id) {
|
||||
throw new Error("Cannot update watchlist: not signed in");
|
||||
}
|
||||
|
||||
// Watchlist == Jellyfin "Likes" rating:
|
||||
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=true - add to watchlist
|
||||
// POST /UserItems/{itemId}/Rating?userId={userId}&likes=false - remove from watchlist
|
||||
const path = `/UserItems/${currentItem.Id}/Rating`;
|
||||
|
||||
const response = await currentApi.post(
|
||||
path,
|
||||
{},
|
||||
{ params: { userId: currentUser.Id, likes: nextIsWatchlisted } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
onMutate: async (nextIsWatchlisted: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: itemQueryKeyPrefix });
|
||||
|
||||
const previousIsWatchlisted = isWatchlisted;
|
||||
const previousQueries = queryClient.getQueriesData<BaseItemDto | null>({
|
||||
queryKey: itemQueryKeyPrefix,
|
||||
});
|
||||
|
||||
setIsWatchlisted(nextIsWatchlisted);
|
||||
updateItemInQueries({ UserData: { Likes: nextIsWatchlisted } });
|
||||
|
||||
return { previousIsWatchlisted, previousQueries };
|
||||
},
|
||||
onError: (error: Error, _nextIsWatchlisted, context) => {
|
||||
// Roll back the optimistic Likes flip applied in onMutate.
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
setIsWatchlisted(context?.previousIsWatchlisted);
|
||||
toast.error(error.message || "Failed to update watchlist");
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
|
||||
// The favorites/watchlist "see all" grid keeps its own infinite query
|
||||
// (["favorites", "see-all", ...]); invalidate it so removing an item
|
||||
// from within the see-all screen updates the list in place.
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites", "see-all"] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleWatchlist = useCallback(() => {
|
||||
// Ignore taps while a flip is in flight so overlapping requests can't
|
||||
// race and leave Jellyfin's Likes value out of sync with the UI.
|
||||
if (watchlistMutation.isPending) return;
|
||||
watchlistMutation.mutate(!isWatchlisted);
|
||||
}, [watchlistMutation, isWatchlisted]);
|
||||
|
||||
return {
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
isPending: watchlistMutation.isPending,
|
||||
watchlistMutation,
|
||||
};
|
||||
};
|
||||
@@ -132,7 +132,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.20.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.8",
|
||||
"react-test-renderer": "19.2.3",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -92,12 +91,6 @@ export const apiAtom = atom<Api | null>(initialApi);
|
||||
export const userAtom = atom<UserDto | null>(initialUser);
|
||||
export const wsAtom = atom<WebSocket | null>(null);
|
||||
export const cacheVersionAtom = atom<number>(0);
|
||||
// Set by a login flow that wants the account saved: the protection picker
|
||||
// shows AFTER the session is authorized (the login screen unmounts on
|
||||
// success, so the modal lives at the root — see PendingAccountSaveModal).
|
||||
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
interface LoginOptions {
|
||||
saveAccount?: boolean;
|
||||
@@ -115,11 +108,6 @@ interface JellyfinContextValue {
|
||||
serverName?: string,
|
||||
options?: LoginOptions,
|
||||
) => Promise<void>;
|
||||
saveCurrentAccount: (options?: {
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
serverName?: string;
|
||||
}) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
initiateQuickConnect: () => Promise<string | undefined>;
|
||||
stopQuickConnectPolling: () => void;
|
||||
@@ -177,46 +165,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// --- Session-expiry handling ----------------------------------------------
|
||||
// When the server revokes the token (e.g. the device/session is deleted), a
|
||||
// 401 can surface from any authenticated request. Without central handling
|
||||
// the dead token stays in storage, so every reload re-fires authed calls →
|
||||
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
|
||||
// state. A single response interceptor on the authenticated api clears the
|
||||
// session on the first 401 so the app drops cleanly to the login screen.
|
||||
const sessionExpiredRef = useRef(false);
|
||||
|
||||
const handleSessionExpired = useCallback(() => {
|
||||
if (sessionExpiredRef.current) return; // run once per session
|
||||
sessionExpiredRef.current = true;
|
||||
storage.remove("token");
|
||||
storage.remove("user");
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
queryClient.clear();
|
||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||
// Saved credentials are kept so the user can quick-login again.
|
||||
}, [setUser, setApi, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only guard an authenticated session. A pre-auth api (login screen) keeps
|
||||
// its own handling — a wrong-password 401 is not a session expiry.
|
||||
if (!api?.accessToken) return;
|
||||
sessionExpiredRef.current = false; // re-arm for this fresh session
|
||||
const interceptorId = api.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
handleSessionExpired();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
api.axiosInstance.interceptors.response.eject(interceptorId);
|
||||
};
|
||||
}, [api, handleSessionExpired]);
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (!deviceId) return {};
|
||||
return {
|
||||
@@ -359,37 +307,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Persist the CURRENT session to secure storage — used by the post-login
|
||||
// save-account modal (the protection picker shows AFTER a successful
|
||||
// login, for both the password and Quick Connect flows).
|
||||
const saveCurrentAccount = useCallback(
|
||||
async (options?: {
|
||||
securityType?: AccountSecurityType;
|
||||
pinCode?: string;
|
||||
serverName?: string;
|
||||
}) => {
|
||||
const token = storage.getString("token");
|
||||
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
|
||||
const securityType = options?.securityType || "none";
|
||||
let pinHash: string | undefined;
|
||||
if (securityType === "pin" && options?.pinCode) {
|
||||
pinHash = await hashPIN(options.pinCode);
|
||||
}
|
||||
await saveAccountCredential({
|
||||
serverUrl: api.basePath,
|
||||
serverName: options?.serverName || "",
|
||||
token,
|
||||
userId: user.Id,
|
||||
username: user.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType,
|
||||
pinHash,
|
||||
primaryImageTag: user.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
},
|
||||
[api?.basePath, user],
|
||||
);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
username,
|
||||
@@ -592,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Expected, handled case (e.g. revoked token → "Session Expired", or
|
||||
// server unreachable): the UI surfaces the message, so warn, don't error.
|
||||
console.warn("Quick login failed:", error);
|
||||
console.error("Quick login failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -705,62 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setUser(storedUser);
|
||||
}
|
||||
|
||||
// Validate the token and refresh user data in the background. Do NOT
|
||||
// await this: the Jellyfin SDK axios instance has no timeout, so when
|
||||
// offline this call hangs for the full OS TCP timeout (75-120s) and
|
||||
// blocks splash dismissal. The cached storedUser (set above) is enough
|
||||
// to render; on success we just refresh it.
|
||||
getUserApi(apiInstance)
|
||||
.getCurrentUser()
|
||||
.then(async (response) => {
|
||||
setUser(response.data);
|
||||
// Dismiss splash screen with cached data immediately,
|
||||
// fetch fresh user data in the background
|
||||
setInitialLoaded(true);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
// Expected, handled case (offline, or a token the server rejects —
|
||||
// the UI prompts re-login): warn, don't error. Log only
|
||||
// status/message — never the raw error (axios errors carry the
|
||||
// request config incl. the Authorization header / token).
|
||||
console.warn(
|
||||
"Background user validation failed:",
|
||||
e?.response?.status ?? e?.message ?? "unknown error",
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
setUser(response.data);
|
||||
|
||||
// Migrate current session to secure storage if not already saved
|
||||
if (storedUser?.Id && storedUser?.Name) {
|
||||
const existingCredential = await getAccountCredential(
|
||||
serverUrl,
|
||||
storedUser.Id,
|
||||
);
|
||||
});
|
||||
if (!existingCredential) {
|
||||
await saveAccountCredential({
|
||||
serverUrl,
|
||||
serverName: "",
|
||||
token,
|
||||
userId: storedUser.Id,
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Background fetch failed — app already rendered with cached data
|
||||
console.warn("Background user fetch failed, using cached data:", e);
|
||||
}
|
||||
} else {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setInitialLoaded(true);
|
||||
}
|
||||
};
|
||||
@@ -774,7 +681,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
removeServer: () => removeServerMutation.mutateAsync(),
|
||||
login: (username, password, serverName, options) =>
|
||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||
saveCurrentAccount,
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
initiateQuickConnect,
|
||||
stopQuickConnectPolling,
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "اختر",
|
||||
"no_trailer_available": "لا يوجد مقطع دعائي متوفر",
|
||||
"video": "فيديو",
|
||||
"audio": "الصوت",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Vídeo",
|
||||
"audio": "Àudio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vybrat",
|
||||
"no_trailer_available": "Přípojné vozidlo není k dispozici",
|
||||
"video": "Video",
|
||||
"audio": "Zvuk",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Vælg",
|
||||
"no_trailer_available": "Intet påhængskøretøj tilgængeligt",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Auswählen",
|
||||
"no_trailer_available": "Kein Trailer verfügbar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Sprache",
|
||||
"results": "Ergebnisse",
|
||||
"searching": "Suche ...",
|
||||
"search_failed": "Suche fehlgeschlagen",
|
||||
"no_subtitle_provider": "Kein Untertitelanbieter auf dem Server konfiguriert",
|
||||
"no_subtitles_found": "Keine Untertitel gefunden",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Επιλογή",
|
||||
"no_trailer_available": "Δεν υπάρχει διαθέσιμο ρυμουλκούμενο",
|
||||
"video": "Βίντεο",
|
||||
"audio": "Ήχος",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -587,8 +588,25 @@
|
||||
"videos": "Videos",
|
||||
"boxsets": "Box sets",
|
||||
"playlists": "Playlists",
|
||||
"seeAllSeries": "Favorited Series",
|
||||
"seeAllMovies": "Favorited Movies",
|
||||
"seeAllEpisodes": "Favorited Episodes",
|
||||
"seeAllVideos": "Favorited Videos",
|
||||
"seeAllBoxsets": "Favorited Box sets",
|
||||
"seeAllPlaylists": "Favorited Playlists",
|
||||
"noDataTitle": "No favorites yet",
|
||||
"noData": "Mark items as favorites to see them appear here for quick access."
|
||||
"noData": "Mark items as favorites to see them appear here for quick access.",
|
||||
"watchlist": "Watchlist"
|
||||
},
|
||||
"kefintweaksWatchlist": {
|
||||
"seeAllSeries": "Watchlisted Series",
|
||||
"seeAllMovies": "Watchlisted Movies",
|
||||
"seeAllEpisodes": "Watchlisted Episodes",
|
||||
"seeAllVideos": "Watchlisted Videos",
|
||||
"seeAllBoxsets": "Watchlisted Box sets",
|
||||
"seeAllPlaylists": "Watchlisted Playlists",
|
||||
"noDataTitle": "No watchlisted items yet",
|
||||
"noData": "Add items to your watchlist to see them appear here."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "No links"
|
||||
@@ -620,6 +638,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seleccionar",
|
||||
"no_trailer_available": "No hay tráiler disponible",
|
||||
"video": "Vídeo",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Valitse",
|
||||
"no_trailer_available": "Perävaunua ei saatavilla",
|
||||
"video": "Video",
|
||||
"audio": "Ääni",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"error_title": "Erreur",
|
||||
"login_title": "Se connecter",
|
||||
"login_to_title": "Se connecter à",
|
||||
"select_user": "Sélectionnez un utilisateur pour vous connecter",
|
||||
"add_user_to_login": "Ajouter un utilisateur pour se connecter",
|
||||
"add_user": "Ajouter un utilisateur",
|
||||
"select_user": "Select a user to log in",
|
||||
"add_user_to_login": "Add a user to log in",
|
||||
"add_user": "Add User",
|
||||
"username_placeholder": "Nom d'utilisateur",
|
||||
"password_placeholder": "Mot de passe",
|
||||
"login_button": "Se connecter",
|
||||
@@ -42,19 +42,19 @@
|
||||
"please_login_again": "Votre session enregistrée a expiré. Veuillez vous connecter à nouveau.",
|
||||
"remove_saved_login": "Supprimer l'identifiant enregistré",
|
||||
"remove_saved_login_description": "Cela supprimera vos identifiants enregistrés pour ce serveur. Vous devrez saisir à nouveau votre nom d’utilisateur et votre mot de passe la prochaine fois.",
|
||||
"accounts_count": "{{count}} comptes",
|
||||
"accounts_count": "Comptes {{count}}",
|
||||
"select_account": "Sélectionnez un compte",
|
||||
"add_account": "Ajouter un compte",
|
||||
"remove_account_description": "Cela supprimera les identifiants enregistrés pour {{username}}.",
|
||||
"remove_server": "Supprimer le serveur",
|
||||
"remove_server_description": "Ceci supprimera {{server}} et tous les comptes enregistrés de votre liste.",
|
||||
"select_your_server": "Sélectionnez votre serveur",
|
||||
"add_server_to_get_started": "Ajouter un serveur pour commencer",
|
||||
"add_server": "Ajouter un serveur",
|
||||
"change_server": "Changer de serveur"
|
||||
"remove_server": "Remove Server",
|
||||
"remove_server_description": "This will remove {{server}} and all saved accounts from your list.",
|
||||
"select_your_server": "Select Your Server",
|
||||
"add_server_to_get_started": "Add a server to get started",
|
||||
"add_server": "Add Server",
|
||||
"change_server": "Change Server"
|
||||
},
|
||||
"save_account": {
|
||||
"title": "Enregistrer le compte",
|
||||
"title": "Sauvegarder le compte",
|
||||
"save_for_later": "Enregistrer ce compte",
|
||||
"security_option": "Options de sécurité",
|
||||
"no_protection": "Aucune protection",
|
||||
@@ -95,9 +95,9 @@
|
||||
"oops": "Oups !",
|
||||
"error_message": "Quelque chose s'est mal passé.\nVeuillez vous reconnecter à nouveau.",
|
||||
"continue_watching": "Continuer à regarder",
|
||||
"continue": "Continuer",
|
||||
"continue": "Continue",
|
||||
"next_up": "À suivre",
|
||||
"continue_and_next_up": "Continuer à regarder et à suivre",
|
||||
"continue_and_next_up": "Continuer de regarder et à suivre",
|
||||
"recently_added_in": "Ajoutés récemment dans {{libraryName}}",
|
||||
"suggested_movies": "Films suggérés",
|
||||
"suggested_episodes": "Épisodes suggérés",
|
||||
@@ -120,10 +120,10 @@
|
||||
"settings_title": "Paramètres",
|
||||
"log_out_button": "Déconnexion",
|
||||
"switch_user": {
|
||||
"title": "Changer d'utilisateur",
|
||||
"account": "Compte",
|
||||
"switch_user": "Changer d'utilisateur sur ce serveur",
|
||||
"current": "actuel"
|
||||
"title": "Switch User",
|
||||
"account": "Account",
|
||||
"switch_user": "Switch User on This Server",
|
||||
"current": "current"
|
||||
},
|
||||
"categories": {
|
||||
"title": "Catégories"
|
||||
@@ -136,32 +136,32 @@
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"merge_next_up_continue_watching": "Fusionner « Continuer à regarder » et « À suivre »",
|
||||
"merge_next_up_continue_watching": "Fusionner, continuer à regarder et à suivre",
|
||||
"hide_remote_session_button": "Masquer le bouton de session distante",
|
||||
"show_home_backdrop": "Arrière-plan d'accueil dynamique",
|
||||
"show_hero_carousel": "Carrousel principal",
|
||||
"show_series_poster_on_episode": "Afficher l'affiche de la série sur les épisodes",
|
||||
"theme_music": "Musique de thème",
|
||||
"display_size": "Taille d'affichage",
|
||||
"display_size_small": "Petite",
|
||||
"display_size_default": "Par défaut",
|
||||
"display_size_large": "Grande",
|
||||
"display_size_extra_large": "Très grande"
|
||||
"show_home_backdrop": "Dynamic Home Backdrop",
|
||||
"show_hero_carousel": "Hero Carousel",
|
||||
"show_series_poster_on_episode": "Show Series Poster on Episodes",
|
||||
"theme_music": "Theme Music",
|
||||
"display_size": "Display Size",
|
||||
"display_size_small": "Small",
|
||||
"display_size_default": "Default",
|
||||
"display_size_large": "Large",
|
||||
"display_size_extra_large": "Extra Large"
|
||||
},
|
||||
"network": {
|
||||
"title": "Réseau",
|
||||
"local_network": "Réseau local",
|
||||
"auto_switch_enabled": "Basculement automatique à la maison",
|
||||
"auto_switch_enabled": "Basculement automatique quand à la maison",
|
||||
"auto_switch_description": "Basculer automatiquement vers l'URL locale lorsque vous êtes connecté au Wi-Fi de la maison",
|
||||
"local_url": "URL locale",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (par exemple http://192.168.1.100:8096)",
|
||||
"local_url_hint": "Entrez l'adresse de votre serveur local (exemple, http://192.168.1.100:8096)",
|
||||
"local_url_placeholder": "http://192.168.1.100:8096",
|
||||
"home_wifi_networks": "Réseaux Wi-Fi domestiques",
|
||||
"add_current_network": "Ajouter \"{{ssid}}\"",
|
||||
"not_connected_to_wifi": "Non connecté au Wi-Fi",
|
||||
"not_connected_to_wifi": "Non connecté au WiFi",
|
||||
"no_networks_configured": "Pas de réseau configuré",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer le basculement automatique",
|
||||
"current_wifi": "Wi-Fi actuel",
|
||||
"add_network_hint": "Ajouter votre réseau Wi-Fi domestique pour activer la commutation automatique",
|
||||
"current_wifi": "WiFi actuel",
|
||||
"using_url": "Utilisant",
|
||||
"local": "URL locale",
|
||||
"remote": "URL à distance",
|
||||
@@ -172,9 +172,9 @@
|
||||
"not_configured": "Non configuré",
|
||||
"network_added": "Réseau ajouté",
|
||||
"network_already_added": "Réseau déjà ajouté",
|
||||
"no_wifi_connected": "Non connecté au Wi-Fi",
|
||||
"no_wifi_connected": "Non connecté au WiFi",
|
||||
"permission_denied": "Autorisation de localisation refusée",
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wi-Fi afin de basculer automatiquement. Veuillez l'activer dans les paramètres."
|
||||
"permission_denied_explanation": "Une autorisation de localisation est requise pour détecter le réseau Wifi afin de changer automatiquement. Veuillez l’activer dans les paramètres."
|
||||
},
|
||||
"user_info": {
|
||||
"user_info_title": "Informations utilisateur",
|
||||
@@ -184,35 +184,35 @@
|
||||
"app_version": "Version de l'application"
|
||||
},
|
||||
"quick_connect": {
|
||||
"quick_connect_title": "Connexion rapide",
|
||||
"authorize_button": "Autoriser une connexion rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de connexion rapide...",
|
||||
"quick_connect_title": "Connexion Rapide",
|
||||
"authorize_button": "Autoriser une Connexion Rapide",
|
||||
"enter_the_quick_connect_code": "Entrez le code de Connexion Rapide...",
|
||||
"success": "Succès",
|
||||
"quick_connect_autorized": "Connexion rapide autorisée",
|
||||
"quick_connect_autorized": "Connexion Rapide autorisé",
|
||||
"error": "Erreur",
|
||||
"invalid_code": "Code invalide",
|
||||
"authorize": "Autoriser"
|
||||
},
|
||||
"media_controls": {
|
||||
"media_controls_title": "Contrôles média",
|
||||
"media_controls_title": "Contrôles Média",
|
||||
"forward_skip_length": "Durée de saut en avant",
|
||||
"rewind_length": "Durée de retour en arrière",
|
||||
"seconds_unit": "s"
|
||||
},
|
||||
"buffer": {
|
||||
"title": "Paramètres du tampon",
|
||||
"cache_mode": "Mode de cache",
|
||||
"title": "Buffer Settings",
|
||||
"cache_mode": "Cache Mode",
|
||||
"cache_auto": "Auto",
|
||||
"cache_yes": "Activé",
|
||||
"cache_no": "Désactivé",
|
||||
"buffer_duration": "Durée du tampon",
|
||||
"max_cache_size": "Taille maximale du cache",
|
||||
"max_backward_cache": "Cache arrière maximal"
|
||||
"cache_yes": "Enabled",
|
||||
"cache_no": "Disabled",
|
||||
"buffer_duration": "Buffer Duration",
|
||||
"max_cache_size": "Max Cache Size",
|
||||
"max_backward_cache": "Max Backward Cache"
|
||||
},
|
||||
"vo_driver": {
|
||||
"title": "Sortie vidéo",
|
||||
"vo_mode": "Pilote VO",
|
||||
"gpu_next": "gpu-next (Recommandé)",
|
||||
"title": "Video Output",
|
||||
"vo_mode": "VO Driver",
|
||||
"gpu_next": "gpu-next (Recommended)",
|
||||
"gpu": "gpu"
|
||||
},
|
||||
"gesture_controls": {
|
||||
@@ -225,8 +225,8 @@
|
||||
"right_side_volume_description": "Glisser vers le haut/bas sur le côté droit pour ajuster le volume",
|
||||
"hide_volume_slider": "Masquer le curseur de volume",
|
||||
"hide_volume_slider_description": "Masquer le curseur de volume dans le lecteur vidéo",
|
||||
"hide_brightness_slider": "Masquer le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de luminosité dans le lecteur vidéo"
|
||||
"hide_brightness_slider": "Cacher le curseur de luminosité",
|
||||
"hide_brightness_slider_description": "Masquer le curseur de volume dans le lecteur vidéo"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -234,14 +234,14 @@
|
||||
"audio_language": "Langue audio",
|
||||
"audio_hint": "Choisissez une langue audio par défaut.",
|
||||
"none": "Aucune",
|
||||
"language": "Langue",
|
||||
"language": "Langage",
|
||||
"transcode_mode": {
|
||||
"title": "Transcodage audio",
|
||||
"description": "Contrôle la gestion de l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Auto",
|
||||
"stereo": "Forcer la stéréo",
|
||||
"5_1": "Autoriser 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
"passthrough": "Intercommunication"
|
||||
}
|
||||
},
|
||||
"subtitles": {
|
||||
@@ -252,7 +252,7 @@
|
||||
"set_subtitle_track": "Piste de sous-titres de l'élément précédent",
|
||||
"subtitle_size": "Taille des sous-titres",
|
||||
"none": "Aucune",
|
||||
"language": "Langue",
|
||||
"language": "Langage",
|
||||
"loading": "Chargement",
|
||||
"modes": {
|
||||
"Default": "Par défaut",
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Forcés seulement"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Entrez votre clé API OpenSubtitles pour activer la recherche de sous-titres côté client comme solution de secours lorsque votre serveur Jellyfin n'a pas de fournisseur de sous-titres configuré.",
|
||||
"opensubtitles_api_key": "Clé API",
|
||||
"opensubtitles_api_key_placeholder": "Entrez la clé API...",
|
||||
"opensubtitles_get_key": "Obtenez votre clé API gratuite sur opensubtitles.com/fr/consumers",
|
||||
"mpv_subtitle_scale": "Échelle des sous-titres",
|
||||
"mpv_subtitle_margin_y": "Marge verticale",
|
||||
"mpv_subtitle_align_x": "Alignement horizontal",
|
||||
"mpv_subtitle_align_y": "Alignement vertical",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"align": {
|
||||
"left": "Gauche",
|
||||
"center": "Centre",
|
||||
"right": "Droite",
|
||||
"top": "Haut",
|
||||
"bottom": "Bas"
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -283,38 +283,38 @@
|
||||
"video_orientation": "Orientation vidéo",
|
||||
"orientation": "Orientation",
|
||||
"orientations": {
|
||||
"DEFAULT": "Suivre l'orientation de l'appareil",
|
||||
"DEFAULT": "Par défaut",
|
||||
"ALL": "Toutes",
|
||||
"PORTRAIT": "Portrait auto",
|
||||
"PORTRAIT_UP": "Portrait haut",
|
||||
"PORTRAIT_DOWN": "Portrait bas",
|
||||
"LANDSCAPE": "Paysage auto",
|
||||
"LANDSCAPE_LEFT": "Paysage gauche",
|
||||
"LANDSCAPE_RIGHT": "Paysage droite",
|
||||
"PORTRAIT": "Portrait",
|
||||
"PORTRAIT_UP": "Portrait Haut",
|
||||
"PORTRAIT_DOWN": "Portrait Bas",
|
||||
"LANDSCAPE": "Paysage",
|
||||
"LANDSCAPE_LEFT": "Paysage Gauche",
|
||||
"LANDSCAPE_RIGHT": "Paysage Droite",
|
||||
"OTHER": "Autre",
|
||||
"UNKNOWN": "Inconnu"
|
||||
},
|
||||
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
|
||||
"show_custom_menu_links": "Afficher les liens personnalisés",
|
||||
"show_large_home_carousel": "Afficher le grand carrousel d’accueil (bêta)",
|
||||
"hide_libraries": "Masquer les bibliothèques",
|
||||
"hide_libraries": "Cacher des bibliothèques",
|
||||
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans l'onglet Bibliothèque et les sections de la page d'accueil.",
|
||||
"disable_haptic_feedback": "Désactiver le retour haptique",
|
||||
"default_quality": "Qualité par défaut",
|
||||
"default_playback_speed": "Vitesse de lecture par défaut",
|
||||
"auto_play_next_episode": "Lecture automatique de l'épisode suivant",
|
||||
"max_auto_play_episode_count": "Nombre max d'épisodes en lecture automatique",
|
||||
"max_auto_play_episode_count": "Nombre d'épisodes en lecture automatique max",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
"playback_title": "Lecture",
|
||||
"playback_description": "Configurer le mode de lecture de la musique.",
|
||||
"prefer_downloaded": "Préférer les musiques téléchargées",
|
||||
"prefer_downloaded": "Supprimer toutes les musiques téléchargées",
|
||||
"caching_title": "Mise en cache",
|
||||
"caching_description": "Mettre automatiquement en cache les pistes à venir pour une lecture plus fluide.",
|
||||
"lookahead_enabled": "Activer la mise en cache anticipée",
|
||||
"lookahead_count": "Pistes à mettre en cache à l'avance",
|
||||
"lookahead_enabled": "Activer la mise en cache guidée",
|
||||
"lookahead_count": "Pistes à pré-mettre en cache",
|
||||
"max_cache_size": "Taille max de cache"
|
||||
},
|
||||
"plugins": {
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Jours de quota de séries",
|
||||
"reset_jellyseerr_config_button": "Réinitialiser la configuration Seerr",
|
||||
"unlimited": "Illimité",
|
||||
"plus_n_more": "+{{n}} de plus",
|
||||
"plus_n_more": "+{{n}} Plus",
|
||||
"order_by": {
|
||||
"DEFAULT": "Par défaut",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Nombre de votes et moyenne",
|
||||
@@ -341,7 +341,7 @@
|
||||
}
|
||||
},
|
||||
"marlin_search": {
|
||||
"enable_marlin_search": "Activer la recherche Marlin",
|
||||
"enable_marlin_search": "Activer Marlin Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://domaine.org:port",
|
||||
"marlin_search_hint": "Entrez l'URL du serveur Marlin. L'URL devrait inclure http ou https et optionnellement le port.",
|
||||
@@ -362,10 +362,10 @@
|
||||
"features_title": "Fonctionnalités",
|
||||
"enable_movie_recommendations": "Recommandations de films",
|
||||
"enable_series_recommendations": "Recommandations de séries",
|
||||
"enable_promoted_watchlists": "Listes de suivi promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de suivi",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de suivi promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films recommandés",
|
||||
"enable_promoted_watchlists": "Listes de lecture promues",
|
||||
"hide_watchlists_tab": "Masquer l'onglet des listes de lecture",
|
||||
"home_sections_hint": "Afficher des recommandations personnalisées et des listes de lecture promues de Streamystats sur la page d’accueil.",
|
||||
"recommended_movies": "Films Recommandés",
|
||||
"recommended_series": "Séries recommandées",
|
||||
"toasts": {
|
||||
"saved": "Enregistré",
|
||||
@@ -375,7 +375,7 @@
|
||||
"refresh_from_server": "Rafraîchir les paramètres depuis le serveur"
|
||||
},
|
||||
"kefinTweaks": {
|
||||
"watchlist_enabler": "Activer l'intégration de la liste de suivi"
|
||||
"watchlist_enabler": "Activer l'intégration de notre liste de lecture"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
@@ -392,10 +392,10 @@
|
||||
"delete_all_downloaded_songs": "Supprimer toutes les musiques téléchargées",
|
||||
"downloaded_songs_size": "{{size}} téléchargé",
|
||||
"downloaded_songs_deleted": "Chansons téléchargées supprimées",
|
||||
"clear_all_cache": "Effacer tout le cache",
|
||||
"clear_all_cache_confirm": "Effacer tout le cache ?",
|
||||
"clear_all_cache_confirm_desc": "Êtes-vous sûr de vouloir effacer toutes les données en cache ? Cela supprimera toutes les images, fichiers musicaux, sous-titres et caches de requêtes mis en cache. Vos paramètres et votre session de connexion seront conservés.",
|
||||
"clear_all_cache_error_desc": "Une erreur est survenue lors de l'effacement du cache."
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Introduction",
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Erreur lors de la suppression des fichiers"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sécurité",
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Délai d'inactivité",
|
||||
"disabled": "Désactivé",
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 heure",
|
||||
"4_hours": "4 heures",
|
||||
"24_hours": "24 heures"
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -446,7 +446,7 @@
|
||||
"delete_all_movies_button": "Supprimer tous les films",
|
||||
"delete_all_series_button": "Supprimer toutes les séries",
|
||||
"delete_all_button": "Supprimer tous les médias",
|
||||
"delete_all_other_media_button": "Supprimer les autres médias",
|
||||
"delete_all_other_media_button": "Supprimer un autre média",
|
||||
"active_download": "Téléchargement actif",
|
||||
"no_active_downloads": "Pas de téléchargements actifs",
|
||||
"active_downloads": "Téléchargements actifs",
|
||||
@@ -454,7 +454,7 @@
|
||||
"new_app_version_requires_re_download_description": "La nouvelle mise à jour nécessite que le contenu soit téléchargé à nouveau. Veuillez supprimer tout le contenu téléchargé et réessayer.",
|
||||
"back": "Retour",
|
||||
"delete": "Supprimer",
|
||||
"delete_download": "Supprimer le téléchargement",
|
||||
"delete_download": "Delete Download",
|
||||
"something_went_wrong": "Quelque chose s'est mal passé",
|
||||
"could_not_get_stream_url_from_jellyfin": "Impossible d'obtenir l'URL du flux depuis Jellyfin",
|
||||
"eta": "ETA {{eta}}",
|
||||
@@ -465,7 +465,7 @@
|
||||
"deleted_all_series_successfully": "Toutes les séries ont été supprimées avec succès !",
|
||||
"failed_to_delete_all_series": "Échec de la suppression de toutes les séries",
|
||||
"deleted_media_successfully": "Les autres médias ont été supprimés avec succès !",
|
||||
"failed_to_delete_media": "Échec de la suppression des autres médias",
|
||||
"failed_to_delete_media": "Échec de la suppression d'un autre média",
|
||||
"download_cancelled": "Téléchargement annulé",
|
||||
"could_not_delete_download": "Impossible de supprimer le téléchargement",
|
||||
"download_completed": "Téléchargement terminé",
|
||||
@@ -483,16 +483,17 @@
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"no_results": "Aucun résultat",
|
||||
"no_results": "No Results",
|
||||
"select": "Sélectionner",
|
||||
"no_trailer_available": "Aucune bande-annonce disponible",
|
||||
"video": "Vidéo",
|
||||
"audio": "Audio",
|
||||
"subtitle": "Sous-titres",
|
||||
"play": "Lecture",
|
||||
"mark_as_played": "Marquer comme vu",
|
||||
"mark_as_not_played": "Marquer comme non vu",
|
||||
"mark_as_played": "Mark as Played",
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"none": "Aucun",
|
||||
"track": "Piste",
|
||||
"track": "Suivre",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"ok": "Ok",
|
||||
@@ -500,15 +501,15 @@
|
||||
"back": "Précédent",
|
||||
"continue": "Continuer",
|
||||
"verifying": "Vérification...",
|
||||
"login": "Connexion",
|
||||
"episodes": "Épisodes",
|
||||
"movies": "Films",
|
||||
"loading": "Chargement…",
|
||||
"seeAll": "Tout afficher"
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Rechercher...",
|
||||
"x_items": "{{count}} médias",
|
||||
"x_items": "{{count}} Médias",
|
||||
"library": "Bibliothèque",
|
||||
"discover": "Découvrir",
|
||||
"no_results": "Aucun résultat",
|
||||
@@ -526,7 +527,7 @@
|
||||
"request_series": "Demander une série",
|
||||
"recently_added": "Ajoutés récemment",
|
||||
"recent_requests": "Demandes récentes",
|
||||
"plex_watchlist": "Liste de suivi Plex",
|
||||
"plex_watchlist": "Liste de lecture Plex",
|
||||
"trending": "Tendance",
|
||||
"popular_movies": "Films populaires",
|
||||
"movie_genres": "Genres de films",
|
||||
@@ -535,7 +536,7 @@
|
||||
"popular_tv": "Séries populaires",
|
||||
"tv_genres": "Genres des séries",
|
||||
"upcoming_tv": "Séries à venir",
|
||||
"networks": "Chaînes",
|
||||
"networks": "Studios",
|
||||
"tmdb_movie_keyword": "Mots-clés de film TMDB",
|
||||
"tmdb_movie_genre": "Genre de film TMDB",
|
||||
"tmdb_tv_keyword": "Mots-clés de séries TMDB",
|
||||
@@ -552,7 +553,7 @@
|
||||
"item_types": {
|
||||
"movies": "Films",
|
||||
"series": "Séries",
|
||||
"boxsets": "Coffrets",
|
||||
"boxsets": "Coffrets ",
|
||||
"playlists": "Playlists",
|
||||
"items": "Médias"
|
||||
},
|
||||
@@ -574,10 +575,10 @@
|
||||
"filter_by": "Filtrer par",
|
||||
"sort_order": "Ordre de tri",
|
||||
"tags": "Tags",
|
||||
"all": "Tout",
|
||||
"reset": "Réinitialiser",
|
||||
"asc": "Croissant",
|
||||
"desc": "Décroissant"
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -591,11 +592,11 @@
|
||||
"noData": "Marquez des éléments comme favoris pour les voir apparaître ici pour un accès rapide."
|
||||
},
|
||||
"custom_links": {
|
||||
"no_links": "Aucun lien"
|
||||
"no_links": "Aucuns liens"
|
||||
},
|
||||
"player": {
|
||||
"live": "EN DIRECT",
|
||||
"mpv_player_title": "Lecteur MPV",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Erreur",
|
||||
"failed_to_get_stream_url": "Échec de l'obtention de l'URL du flux",
|
||||
"an_error_occured_while_playing_the_video": "Une erreur s’est produite lors de la lecture de la vidéo. Vérifiez les journaux dans les paramètres.",
|
||||
@@ -610,71 +611,72 @@
|
||||
"downloaded_file_yes": "Oui",
|
||||
"downloaded_file_no": "Non",
|
||||
"downloaded_file_cancel": "Annuler",
|
||||
"swipe_down_settings": "Balayez vers le bas pour les paramètres",
|
||||
"ends_at": "Se termine à {{time}}",
|
||||
"search_subtitles": "Rechercher des sous-titres",
|
||||
"subtitle_tracks": "Pistes",
|
||||
"subtitle_search": "Rechercher et télécharger",
|
||||
"download": "Télécharger",
|
||||
"subtitle_download_hint": "Les sous-titres téléchargés seront enregistrés dans votre bibliothèque",
|
||||
"using_jellyfin_server": "Utilisation du serveur Jellyfin",
|
||||
"language": "Langue",
|
||||
"results": "Résultats",
|
||||
"search_failed": "Recherche échouée",
|
||||
"no_subtitle_provider": "Aucun fournisseur de sous-titres configuré sur le serveur",
|
||||
"no_subtitles_found": "Aucun sous-titre trouvé",
|
||||
"add_opensubtitles_key_hint": "Ajoutez une clé API OpenSubtitles dans les paramètres pour une solution de secours côté client",
|
||||
"settings": "Paramètres",
|
||||
"skip_intro": "Passer l'intro",
|
||||
"skip_credits": "Passer le générique",
|
||||
"stopPlayback": "Arrêter la lecture",
|
||||
"stopPlayingTitle": "Arrêter de lire \"{{title}}\" ?",
|
||||
"stopPlayingConfirm": "Êtes-vous sûr de vouloir arrêter la lecture ?",
|
||||
"downloaded": "Téléchargé",
|
||||
"missing_parameters": "Paramètres de lecture manquants"
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Chapitres",
|
||||
"chapter_number": "Chapitre {{number}}",
|
||||
"open": "Ouvrir les chapitres",
|
||||
"close": "Fermer les chapitres"
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "À suivre",
|
||||
"no_items_to_display": "Aucun média à afficher",
|
||||
"no_items_to_display": "Aucuns médias à afficher",
|
||||
"cast_and_crew": "Distribution et équipe",
|
||||
"series": "Séries",
|
||||
"seasons": "Saisons",
|
||||
"season": "Saison",
|
||||
"from_this_series": "De cette série",
|
||||
"more_from_this_season": "Plus de cette saison",
|
||||
"view_series": "Voir la série",
|
||||
"view_season": "Voir la saison",
|
||||
"select_season": "Sélectionner une saison",
|
||||
"from_this_series": "From This Series",
|
||||
"more_from_this_season": "More from this Season",
|
||||
"view_series": "View Series",
|
||||
"view_season": "View Season",
|
||||
"select_season": "Select Season",
|
||||
"no_episodes_for_this_season": "Aucun épisode pour cette saison",
|
||||
"overview": "Aperçu",
|
||||
"more_with": "Plus avec {{name}}",
|
||||
"similar_items": "Médias similaires",
|
||||
"no_similar_items_found": "Aucun média similaire trouvé",
|
||||
"no_similar_items_found": "Aucuns médias similaires trouvés",
|
||||
"video": "Vidéo",
|
||||
"more_details": "Plus de détails",
|
||||
"media_options": "Options média",
|
||||
"quality": "Qualité",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Sous-titres",
|
||||
"none": "Aucun",
|
||||
"tracks": "Pistes"
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
},
|
||||
"show_more": "Afficher plus",
|
||||
"show_less": "Afficher moins",
|
||||
"left": "restant",
|
||||
"director": "Réalisateur",
|
||||
"cast": "Distribution",
|
||||
"technical_details": "Détails techniques",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Apparu dans",
|
||||
"movies": "Films",
|
||||
"shows": "Séries",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"could_not_load_item": "Impossible de charger le média",
|
||||
"none": "Aucun",
|
||||
"download": {
|
||||
@@ -686,13 +688,13 @@
|
||||
"download_unwatched_only": "Non visionné uniquement",
|
||||
"download_button": "Télécharger"
|
||||
},
|
||||
"mark_played": "Marquer comme vu",
|
||||
"mark_unplayed": "Marquer comme non vu",
|
||||
"resume_playback": "Reprendre la lecture",
|
||||
"resume_playback_description": "Voulez-vous continuer où vous vous êtes arrêté ou commencer à partir du début ?",
|
||||
"play_from_start": "Lire depuis le début",
|
||||
"continue_from": "Continuer depuis {{time}}",
|
||||
"no_data_available": "Aucune donnée disponible"
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Suivant",
|
||||
@@ -704,16 +706,16 @@
|
||||
"sports": "Sports",
|
||||
"for_kids": "Pour enfants",
|
||||
"news": "Actualités",
|
||||
"page_of": "Page {{current}} sur {{total}}",
|
||||
"no_programs": "Aucun programme disponible",
|
||||
"no_channels": "Aucune chaîne disponible",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"tabs": {
|
||||
"programs": "Programmes",
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Chaînes",
|
||||
"recordings": "Enregistrements",
|
||||
"schedule": "Programmation",
|
||||
"series": "Séries"
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -723,7 +725,7 @@
|
||||
"whats_wrong": "Quel est le problème ?",
|
||||
"issue_type": "Type de problème",
|
||||
"select_an_issue": "Sélectionnez un problème",
|
||||
"types": "Types",
|
||||
"types": "Types de fichiers",
|
||||
"describe_the_issue": "(optionnel) Décrivez le problème...",
|
||||
"submit_button": "Soumettre",
|
||||
"report_issue_button": "Signaler un problème",
|
||||
@@ -732,7 +734,7 @@
|
||||
"failed_to_login": "Échec de la connexion",
|
||||
"cast": "Distribution",
|
||||
"details": "Détails",
|
||||
"status": "Statut",
|
||||
"status": "Statuts",
|
||||
"original_title": "Titre original",
|
||||
"series_type": "Type de série",
|
||||
"release_dates": "Dates de sortie",
|
||||
@@ -750,32 +752,32 @@
|
||||
"tags": "Tags",
|
||||
"quality_profile": "Profil de qualité",
|
||||
"root_folder": "Dossier racine",
|
||||
"season_all": "Saison (Toutes)",
|
||||
"season_all": "Saison (Tous)",
|
||||
"season_number": "Saison {{season_number}}",
|
||||
"number_episodes": "{{episode_number}} épisodes",
|
||||
"number_episodes": "{{episode_number}} Épisodes",
|
||||
"born": "Né(e) le",
|
||||
"appearances": "Apparitions",
|
||||
"appearances": "Apparences",
|
||||
"approve": "Valider",
|
||||
"decline": "Refuser",
|
||||
"requested_by": "Demandé par {{user}}",
|
||||
"unknown_user": "Utilisateur inconnu",
|
||||
"select": "Sélectionner",
|
||||
"request_all": "Tout demander",
|
||||
"request_seasons": "Demander des saisons",
|
||||
"select_seasons": "Sélectionner les saisons",
|
||||
"request_selected": "Demander la sélection",
|
||||
"n_selected": "{{count}} sélectionnés",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr ne répond pas aux exigences ! Veuillez mettre à jour au moins vers la version 2.0.0.",
|
||||
"jellyseerr_test_failed": "Le test Seerr a échoué. Veuillez réessayer.",
|
||||
"failed_to_test_jellyseerr_server_url": "Échec du test de l'URL du serveur Seerr",
|
||||
"issue_submitted": "Problème soumis !",
|
||||
"requested_item": "{{item}} demandé !",
|
||||
"requested_item": "{{item}}} demandé !",
|
||||
"you_dont_have_permission_to_request": "Vous n'avez pas la permission de demander !",
|
||||
"something_went_wrong_requesting_media": "Une erreur s'est produite lors de la demande du média !",
|
||||
"something_went_wrong_requesting_media": "Quelque chose s'est mal passé en demandant le média !",
|
||||
"request_approved": "Demande approuvée !",
|
||||
"request_declined": "Demande refusée !",
|
||||
"failed_to_approve_request": "Échec de l'approbation de la demande",
|
||||
"request_declined": "Demande déclinée !",
|
||||
"failed_to_approve_request": "Échec d'approbation de la demande",
|
||||
"failed_to_decline_request": "Échec du refus de la demande"
|
||||
}
|
||||
},
|
||||
@@ -785,7 +787,7 @@
|
||||
"library": "Bibliothèque",
|
||||
"custom_links": "Liens personnalisés",
|
||||
"favorites": "Favoris",
|
||||
"settings": "Réglages"
|
||||
"settings": "Settings"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musique",
|
||||
@@ -805,13 +807,13 @@
|
||||
"play_top_tracks": "Jouer les pistes les plus populaires",
|
||||
"no_suggestions": "Pas de suggestion disponible",
|
||||
"no_albums": "Pas d'albums trouvés",
|
||||
"no_artists": "Pas d'artistes trouvés",
|
||||
"no_artists": "Pas d'artistes trouvé",
|
||||
"no_playlists": "Pas de playlists trouvées",
|
||||
"album_not_found": "Album introuvable",
|
||||
"artist_not_found": "Artiste introuvable",
|
||||
"playlist_not_found": "Playlist introuvable",
|
||||
"track_options": {
|
||||
"play_next": "Lire ensuite",
|
||||
"play_next": "Lecture suivante",
|
||||
"add_to_queue": "Ajouter à la file d'attente",
|
||||
"add_to_playlist": "Ajouter à la playlist",
|
||||
"download": "Télécharger",
|
||||
@@ -820,15 +822,15 @@
|
||||
"cached": "En cache",
|
||||
"delete_download": "Supprimer un téléchargement",
|
||||
"delete_cache": "Supprimer du cache",
|
||||
"go_to_artist": "Aller à l'artiste",
|
||||
"go_to_album": "Aller à l'album",
|
||||
"go_to_artist": "Voir l'artiste",
|
||||
"go_to_album": "Aller à l’album",
|
||||
"add_to_favorites": "Ajouter aux favoris",
|
||||
"remove_from_favorites": "Retirer des favoris",
|
||||
"remove_from_playlist": "Retirer de la playlist"
|
||||
},
|
||||
"playlists": {
|
||||
"create_playlist": "Créer une playlist",
|
||||
"playlist_name": "Nom de la playlist",
|
||||
"create_playlist": "Créer une Playlist",
|
||||
"playlist_name": "Nom de la Playlist",
|
||||
"enter_name": "Entrer le nom de la playlist",
|
||||
"create": "Créer",
|
||||
"search_playlists": "Rechercher des playlists...",
|
||||
@@ -839,10 +841,10 @@
|
||||
"created": "Playlist créée",
|
||||
"create_new": "Créer une nouvelle playlist",
|
||||
"failed_to_add": "Échec de l'ajout à la playlist",
|
||||
"failed_to_remove": "Échec du retrait de la playlist",
|
||||
"failed_to_create": "Échec de la création de la playlist",
|
||||
"failed_to_remove": "Échec de la suppression de la playlist",
|
||||
"failed_to_create": "Échec de la suppression de la playlist",
|
||||
"delete_playlist": "Supprimer la playlist",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{name}} » ? Cette action est irréversible.",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer « {{ name }} » ? Cette action est irréversible.",
|
||||
"deleted": "Playlist supprimée",
|
||||
"failed_to_delete": "Échec de la suppression de la playlist"
|
||||
},
|
||||
@@ -853,46 +855,46 @@
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Listes de suivi",
|
||||
"my_watchlists": "Mes listes de suivi",
|
||||
"public_watchlists": "Listes de suivi publiques",
|
||||
"create_title": "Créer une liste de suivi",
|
||||
"edit_title": "Modifier la liste de suivi",
|
||||
"create_button": "Créer une liste de suivi",
|
||||
"title": "Listes de lecture",
|
||||
"my_watchlists": "Mes listes de lecture",
|
||||
"public_watchlists": "Watchlist publique",
|
||||
"create_title": "Créer une Watchlist",
|
||||
"edit_title": "Modifier la Watchlist",
|
||||
"create_button": "Créer une Watchlist",
|
||||
"save_button": "Enregistrer les modifications",
|
||||
"delete_button": "Supprimer",
|
||||
"remove_button": "Retirer",
|
||||
"cancel_button": "Annuler",
|
||||
"name_label": "Nom",
|
||||
"name_placeholder": "Entrez le nom de la liste de suivi",
|
||||
"name_placeholder": "Entrer le nom de la playlist",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Entrez la description (facultatif)",
|
||||
"is_public_label": "Liste de suivi publique",
|
||||
"is_public_label": "Liste de lecture Publique",
|
||||
"is_public_description": "Autoriser d'autres personnes à voir cette liste de suivi",
|
||||
"allowed_type_label": "Type de contenu",
|
||||
"sort_order_label": "Ordre de tri par défaut",
|
||||
"empty_title": "Aucune liste de suivi",
|
||||
"empty_title": "Pas de Watchlists",
|
||||
"empty_description": "Créez votre première liste de suivi pour commencer à organiser vos médias",
|
||||
"empty_watchlist": "Cette liste de suivi est vide",
|
||||
"empty_watchlist_hint": "Ajouter des éléments de votre bibliothèque à cette liste de suivi",
|
||||
"not_configured_title": "Streamystats non configuré",
|
||||
"not_configured_description": "Configurer Streamystats dans les paramètres pour utiliser les listes de suivi",
|
||||
"go_to_settings": "Accédez aux paramètres",
|
||||
"add_to_watchlist": "Ajouter à la liste de suivi",
|
||||
"remove_from_watchlist": "Retirer de la liste de suivi",
|
||||
"go_to_settings": "Accédez aux Paramètres",
|
||||
"add_to_watchlist": "Ajouter à la Watchlist",
|
||||
"remove_from_watchlist": "Retirer de la Watchlist",
|
||||
"select_watchlist": "Sélectionner la liste de suivi",
|
||||
"create_new": "Créer une nouvelle liste de suivi",
|
||||
"item": "élément",
|
||||
"items": "éléments",
|
||||
"create_new": "Créer une Watchlist",
|
||||
"item": "médias",
|
||||
"items": "élément",
|
||||
"public": "Publique",
|
||||
"private": "Privée",
|
||||
"you": "Vous",
|
||||
"you": "Vous-même",
|
||||
"by_owner": "Par un autre utilisateur",
|
||||
"not_found": "Liste de suivi introuvable",
|
||||
"delete_confirm_title": "Supprimer la liste de suivi",
|
||||
"delete_confirm_message": "Voulez-vous vraiment supprimer « {{name}} » ? Cette action est irréversible.",
|
||||
"remove_item_title": "Retirer de la liste de suivi",
|
||||
"remove_item_message": "Retirer « {{name}} » de cette liste de suivi ?",
|
||||
"not_found": "Playlist introuvable",
|
||||
"delete_confirm_title": "Supprimer la Watchlist",
|
||||
"delete_confirm_message": "Tous les médias (par défaut)",
|
||||
"remove_item_title": "Retirer de la Watchlist",
|
||||
"remove_item_message": "Retirer «{{name}}» de cette liste de suivi?",
|
||||
"loading": "Chargement des listes de suivi...",
|
||||
"no_compatible_watchlists": "Aucune liste de suivi compatible",
|
||||
"create_one_first": "Créer une liste de suivi qui accepte ce type de contenu"
|
||||
@@ -908,33 +910,33 @@
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Associer à la TV",
|
||||
"align_qr": "Alignez le code QR dans le cadre",
|
||||
"enter_code_manually": "Saisir le code manuellement",
|
||||
"pairing_enter_credentials": "Entrez les identifiants pour le téléviseur",
|
||||
"pairing_code_label": "Code d'appairage",
|
||||
"server": "Serveur",
|
||||
"authorize_button": "Autoriser",
|
||||
"authorizing": "Autorisation en cours...",
|
||||
"scan_again": "Scanner à nouveau",
|
||||
"done": "Terminé",
|
||||
"success_title": "Autorisation envoyée",
|
||||
"pairing_tv_connecting": "La TV se connecte à votre compte",
|
||||
"error_title": "Échec de l'autorisation",
|
||||
"error_invalid_qr": "Code QR invalide. Veuillez scanner le code d'appairage du téléviseur.",
|
||||
"error_generic": "Une erreur s'est produite. Veuillez réessayer.",
|
||||
"error_permission_denied": "L'accès à la caméra est nécessaire pour scanner les QR codes.",
|
||||
"login_as": "Se connecter en tant que {{username}} ?",
|
||||
"on_server": "sur {{server}}",
|
||||
"use_different_user": "Utiliser un autre utilisateur",
|
||||
"open_settings": "Ouvrir les paramètres"
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"server": "Server",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Done",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Associer au téléphone",
|
||||
"pair_with_phone_title": "Se connecter sur la TV",
|
||||
"waiting_for_phone": "En attente du téléphone...",
|
||||
"scan_with_phone": "Scanner avec l'application Streamyfin sur votre téléphone",
|
||||
"logging_in": "Connexion en cours...",
|
||||
"logging_in_description": "Connexion à votre serveur"
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "בחר",
|
||||
"no_trailer_available": "אין טריילר זמין",
|
||||
"video": "וידאו",
|
||||
"audio": "שמע",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Videó",
|
||||
"audio": "Hang",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -237,10 +237,10 @@
|
||||
"language": "Lingua",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Automatico",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Consenti 5.1",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Solo forzati"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
|
||||
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"align": {
|
||||
"left": "Sinistra",
|
||||
"center": "Centro",
|
||||
"right": "Destra",
|
||||
"top": "Alto",
|
||||
"bottom": "Basso"
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -307,9 +307,9 @@
|
||||
"disabled": "Disabilitato"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musica",
|
||||
"playback_title": "Riproduzione",
|
||||
"playback_description": "Configura come viene riprodotta la musica.",
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Giorni di quota per le serie TV",
|
||||
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
||||
"unlimited": "Illimitato",
|
||||
"plus_n_more": "+{{n}} altro",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Predefinito",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
|
||||
@@ -352,25 +352,25 @@
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"disable_streamystats": "Disabilita Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save": "Salva",
|
||||
"features_title": "Funzionalità",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Salvato",
|
||||
"refreshed": "Impostazioni aggiornate dal server",
|
||||
"disabled": "Streamystats disabilitato"
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
@@ -385,17 +385,17 @@
|
||||
"size_used": "{{used}} di {{total}} usato",
|
||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} nella cache",
|
||||
"music_cache_cleared": "Cache musicale cancellata",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} scaricato",
|
||||
"downloaded_songs_deleted": "Brani scaricati eliminati",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
|
||||
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
@@ -404,8 +404,8 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Log",
|
||||
"export_logs": "Esporta i logs",
|
||||
"click_for_more_info": "Clicca per maggiori informazioni",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Livello",
|
||||
"no_logs_available": "Nessun log disponibile",
|
||||
"delete_all_logs": "Cancella tutti i log"
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicurezza",
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabilitato",
|
||||
"1_minute": "1 minuto",
|
||||
"5_minutes": "5 minuti",
|
||||
"15_minutes": "15 minuti",
|
||||
"30_minutes": "30 minuti",
|
||||
"1_hour": "1 ora",
|
||||
"4_hours": "4 ore",
|
||||
"24_hours": "24 ore"
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seleziona",
|
||||
"no_trailer_available": "Nessun trailer disponibile",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -493,18 +494,18 @@
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"none": "Nulla",
|
||||
"track": "Traccia",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Cancella",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Rimuovi",
|
||||
"back": "Indietro",
|
||||
"continue": "Continua",
|
||||
"verifying": "Verifica in corso...",
|
||||
"login": "Accedi",
|
||||
"episodes": "Episodi",
|
||||
"movies": "Film",
|
||||
"loading": "Caricamento…",
|
||||
"seeAll": "Visualizza tutti"
|
||||
"remove": "Remove",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -518,10 +519,10 @@
|
||||
"episodes": "Episodi",
|
||||
"collections": "Collezioni",
|
||||
"actors": "Attori",
|
||||
"artists": "Artisti",
|
||||
"albums": "Album",
|
||||
"songs": "Tracce",
|
||||
"playlists": "Playlist",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film Richiesti",
|
||||
"request_series": "Serie Richieste",
|
||||
"recently_added": "Aggiunti di Recente",
|
||||
@@ -553,7 +554,7 @@
|
||||
"movies": "film",
|
||||
"series": "serie TV",
|
||||
"boxsets": "cofanetti",
|
||||
"playlists": "Playlist",
|
||||
"playlists": "Playlists",
|
||||
"items": "elementi"
|
||||
},
|
||||
"options": {
|
||||
@@ -565,7 +566,7 @@
|
||||
"cover": "Copertina",
|
||||
"show_titles": "Mostra titoli",
|
||||
"show_stats": "Mostra statistiche",
|
||||
"options_title": "Impostazioni"
|
||||
"options_title": "Options"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Generi",
|
||||
@@ -574,10 +575,10 @@
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Criterio di ordinamento",
|
||||
"tags": "Tag",
|
||||
"all": "Tutto",
|
||||
"reset": "Ripristina",
|
||||
"asc": "Crescente",
|
||||
"desc": "Decrescente"
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -594,7 +595,7 @@
|
||||
"no_links": "Nessun link"
|
||||
},
|
||||
"player": {
|
||||
"live": "IN DIRETTA",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Errore",
|
||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
||||
@@ -605,39 +606,40 @@
|
||||
"next_episode": "Prossimo Episodio",
|
||||
"continue_watching": "Continua a guardare",
|
||||
"go_back": "Indietro",
|
||||
"downloaded_file_title": "Questo file è stato scaricato",
|
||||
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
|
||||
"downloaded_file_yes": "Si",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Annulla",
|
||||
"swipe_down_settings": "Scorri in basso per le impostazioni",
|
||||
"ends_at": "Termina alle {{time}}",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracce",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Scarica",
|
||||
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Lingua",
|
||||
"results": "Risultati",
|
||||
"search_failed": "Ricerca fallita",
|
||||
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
|
||||
"no_subtitles_found": "Nessun sottotitolo trovato",
|
||||
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
|
||||
"settings": "Impostazioni",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
|
||||
"downloaded": "Scaricato",
|
||||
"missing_parameters": "Parametri di riproduzione mancanti"
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Capitoli",
|
||||
"chapter_number": "Capitolo {{number}}",
|
||||
"open": "Apri capitoli",
|
||||
"close": "Chiudi i capitoli"
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Il prossimo",
|
||||
@@ -662,19 +664,19 @@
|
||||
"quality": "Qualità",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Sottotitoli",
|
||||
"none": "Vuoto",
|
||||
"tracks": "Tracce"
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
},
|
||||
"show_more": "Mostra di più",
|
||||
"show_less": "Mostra di meno",
|
||||
"left": "sinistra",
|
||||
"director": "Regista",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Apparso in",
|
||||
"movies": "Film",
|
||||
"shows": "Serie",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
||||
"none": "Nessuno",
|
||||
"download": {
|
||||
@@ -689,10 +691,10 @@
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continua da {{time}}",
|
||||
"no_data_available": "Nessun dato disponibile"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Prossimo",
|
||||
@@ -704,16 +706,16 @@
|
||||
"sports": "Sport",
|
||||
"for_kids": "Per Bambini",
|
||||
"news": "Notiziari",
|
||||
"page_of": "Pagina {{current}} di {{total}}",
|
||||
"no_programs": "Nessun programma disponibile",
|
||||
"no_channels": "Nessun canale disponibile",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"tabs": {
|
||||
"programs": "Programmi",
|
||||
"guide": "Guida",
|
||||
"channels": "Canali",
|
||||
"recordings": "Registrazioni",
|
||||
"schedule": "Pianifica",
|
||||
"series": "Serie Tv"
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -759,12 +761,12 @@
|
||||
"decline": "Rifiuta",
|
||||
"requested_by": "Richiesto da {{user}}",
|
||||
"unknown_user": "Utente Sconosciuto",
|
||||
"select": "Seleziona",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selezionati",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
||||
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
||||
@@ -785,39 +787,39 @@
|
||||
"library": "Libreria",
|
||||
"custom_links": "Collegamenti personalizzati",
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Impostazioni"
|
||||
"settings": "Settings"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musica",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggerimenti",
|
||||
"albums": "Album",
|
||||
"artists": "Artisti",
|
||||
"playlists": "Playlist",
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Riproduci",
|
||||
"shuffle": "Riproduzione casuale",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "Nessun suggerimento disponibile",
|
||||
"no_albums": "Nessun album trovato",
|
||||
"no_artists": "Artista non trovato",
|
||||
"no_playlists": "Nessuna playlist trovata",
|
||||
"album_not_found": "Album non trovato",
|
||||
"artist_not_found": "Artista non trovato",
|
||||
"playlist_not_found": "Playlist non trovata",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Scarica",
|
||||
"downloaded": "Scaricato",
|
||||
"downloading": "Scaricamento...",
|
||||
"cached": "Memorizzato nella cache",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
@@ -829,112 +831,112 @@
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Inserisci il nome della playlist",
|
||||
"create": "Crea",
|
||||
"search_playlists": "Cerca playlist...",
|
||||
"added_to": "Aggiunto a {{name}}",
|
||||
"added": "Aggiunto alla playlist",
|
||||
"removed_from": "Rimosso da {{name}}",
|
||||
"removed": "Rimosso dalla playlist",
|
||||
"created": "Playlist creata",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Impossibile aggiungere alla playlist",
|
||||
"failed_to_remove": "Impossibile rimuovere dalla playlist",
|
||||
"failed_to_create": "Impossibile creare la playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"deleted": "Playlist eliminata",
|
||||
"failed_to_delete": "Impossibile eliminare la playlist"
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alfabetico",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Da vedere",
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Cancella",
|
||||
"remove_button": "Rimuovi",
|
||||
"cancel_button": "Annulla",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
|
||||
"description_label": "Descrizione",
|
||||
"description_placeholder": "Inserisci descrizione (opzionale)",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Permetti ad altri di vedere questa lista",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
|
||||
"empty_watchlist": "Questa lista è vuota",
|
||||
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
|
||||
"go_to_settings": "Vai alle impostazioni",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "elemento",
|
||||
"items": "elementi",
|
||||
"public": "Pubblico",
|
||||
"private": "Privato",
|
||||
"you": "Tu",
|
||||
"by_owner": "Da un altro utente",
|
||||
"not_found": "\"Da vedere\" non trovata",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
|
||||
"loading": "Caricamento liste...",
|
||||
"no_compatible_watchlists": "Nessuna lista compatibile",
|
||||
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Velocità",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Solo questo media",
|
||||
"show": "Questo show",
|
||||
"all": "Tutti i media (predefinito)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Associa con la TV",
|
||||
"align_qr": "Allinea il QR code all'interno del riquadro",
|
||||
"enter_code_manually": "Inserisci il codice manualmente",
|
||||
"pairing_enter_credentials": "Inserire le credenziali per la TV",
|
||||
"pairing_code_label": "Codice di associazione",
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"server": "Server",
|
||||
"authorize_button": "Autorizza",
|
||||
"authorizing": "Autorizzando...",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Fatto",
|
||||
"done": "Done",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
|
||||
"error_generic": "Si è verificato un errore. Riprova.",
|
||||
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
|
||||
"login_as": "Accedi come {{username}}?",
|
||||
"on_server": "su {{server}}",
|
||||
"use_different_user": "Usa un altro utente",
|
||||
"open_settings": "Apri le impostazioni"
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"waiting_for_phone": "In attesa del telefono...",
|
||||
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
|
||||
"logging_in": "Accesso in corso...",
|
||||
"logging_in_description": "Sto connettendo al server"
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "選択",
|
||||
"no_trailer_available": "トレーラーがありません",
|
||||
"video": "映像",
|
||||
"audio": "音声",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selecteren",
|
||||
"no_trailer_available": "Geen trailer beschikbaar",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Velg",
|
||||
"no_trailer_available": "Ingen trailer tilgjengelig",
|
||||
"video": "Video",
|
||||
"audio": "Lyd",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Wybierz",
|
||||
"no_trailer_available": "Brak dostępnego zwiastunu",
|
||||
"video": "Wideo",
|
||||
"audio": "Dźwięk",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selecionar",
|
||||
"no_trailer_available": "Nenhum trailer disponível",
|
||||
"video": "Vídeo",
|
||||
"audio": "Áudio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Selectare",
|
||||
"no_trailer_available": "Nicio remorcă disponibilă",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Выбрать",
|
||||
"no_trailer_available": "Трейлер недоступен",
|
||||
"video": "Видео",
|
||||
"audio": "Звук",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "Inga resultat",
|
||||
"select": "Välj",
|
||||
"no_trailer_available": "Ingen trailer tillgänglig",
|
||||
"video": "Video",
|
||||
"audio": "Ljud",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Använder Jellyfin-server",
|
||||
"language": "Språk",
|
||||
"results": "Resultat",
|
||||
"searching": "Söker...",
|
||||
"search_failed": "Sökningen misslyckades",
|
||||
"no_subtitle_provider": "Ingen undertextleverantör konfigurerad på servern",
|
||||
"no_subtitles_found": "Inga undertexter hittades",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "mu'tlhegh",
|
||||
"audio": "QoQ",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Seç",
|
||||
"no_trailer_available": "Fragman mevcut değil",
|
||||
"video": "Video",
|
||||
"audio": "Ses",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Âm thanh",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -484,6 +484,7 @@
|
||||
},
|
||||
"common": {
|
||||
"no_results": "No Results",
|
||||
"select": "Select",
|
||||
"no_trailer_available": "No trailer available",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
@@ -620,6 +621,7 @@
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
|
||||
@@ -82,8 +82,6 @@ export const useFilterOptions = () => {
|
||||
{ key: FilterByOption.IsFavorite, value: "Is Favorite" },
|
||||
{ key: FilterByOption.IsResumable, value: "Is Resumable" },
|
||||
];
|
||||
console.log("filterOptions");
|
||||
console.log(filterOptions);
|
||||
return filterOptions;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,15 +13,6 @@ export type TVOptionModalState = {
|
||||
onSelect: (value: any) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
/**
|
||||
* Run onSelect AFTER the modal route is dismissed. Needed only when onSelect
|
||||
* navigates (the in-player audio switch replacing the player while
|
||||
* transcoding), which the still-active modal route would otherwise swallow.
|
||||
* Default (false) runs onSelect before closing, so state-only callers (detail
|
||||
* page, library filters, settings) don't re-render after focus returns and
|
||||
* lose TV focus.
|
||||
*/
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
} | null;
|
||||
|
||||
export const tvOptionModalAtom = atom<TVOptionModalState>(null);
|
||||
|
||||
Reference in New Issue
Block a user