Compare commits

..

2 Commits

Author SHA1 Message Date
Gauvain
211657b042 ci(lint): stop re-running heavy jobs on PR title edits
The quality gate ran its full matrix on every PR edit event even though
title/body edits cannot change the code: only validate_pr_title needs
them. Gate the heavy jobs on action != 'edited', add the missing
concurrency group (Renovate PRs currently run the whole gate twice per
commit), cache Bun dependencies like the other workflows, drop the
unused setup-node step, and run checkout/Bun setup in parallel.
2026-07-05 03:22:49 +02:00
Gauvain
1b51342888 ci: run independent setup steps in parallel
Use the GitHub Actions parallel-steps feature (2026-06-25) to group
setup steps with no dependency between them: checkout, toolchains
(Bun/JDK/Xcode), the EAS CLI install and the disk-space cleanup now run
concurrently, and the Bun/Gradle cache restores overlap. On the iOS
jobs, Xcode/EAS setup previously ran after the ~2 min prebuild despite
not needing it.

Also in release.yml: queue successive release runs in order
(queue: max) instead of dropping the extra pending run, and fix the
expo-github-action pin comment (the pinned SHA is tag v9.0.0, not
main).
2026-07-05 03:22:42 +02:00
26 changed files with 518 additions and 1102 deletions

View File

@@ -30,64 +30,66 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 🗑️ Free Disk Space - parallel:
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 - name: 🗑️ Free Disk Space
with: uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
tool-cache: false with:
mandb: true tool-cache: false
android: false mandb: true
dotnet: true android: false
haskell: true dotnet: true
large-packages: false haskell: true
docker-images: true large-packages: false
swap-storage: false docker-images: true
swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: ☕ Set up JDK 17
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
with: # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
path: ~/.bun/install/cache # fail). Pin Temurin 17 for a deterministic Android build.
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
restore-keys: | with:
${{ runner.os }}-${{ runner.arch }}-bun- distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules - name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild run: bun run prebuild
@@ -130,64 +132,66 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 🗑️ Free Disk Space - parallel:
uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3 - name: 🗑️ Free Disk Space
with: uses: BRAINSia/free-disk-space@7048ffbf50819342ac964ef3998a51c2564a8a75 # v2.1.3
tool-cache: false with:
mandb: true tool-cache: false
android: false mandb: true
dotnet: true android: false
haskell: true dotnet: true
large-packages: false haskell: true
docker-images: true large-packages: false
swap-storage: false docker-images: true
swap-storage: false
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 fetch-depth: 0
submodules: recursive submodules: recursive
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies - name: ☕ Set up JDK 17
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 # ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
with: # (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
path: ~/.bun/install/cache # fail). Pin Temurin 17 for a deterministic Android build.
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }} uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
restore-keys: | with:
${{ runner.os }}-${{ runner.arch }}-bun- distribution: temurin
java-version: "17"
- parallel:
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 📦 Install dependencies and reload submodules - name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: ☕ Set up JDK 17
# ubuntu-26.04 defaults to JDK 25, which breaks the RN/AGP native build
# (Kotlin falls back to JVM_23, the foojay toolchain + CMake configure
# fail). Pin Temurin 17 for a deterministic Android build.
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
with:
distribution: temurin
java-version: "17"
- name: 💾 Cache Gradle global
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/.gradle/caches/modules-2
~/.gradle/wrapper
key: ${{ runner.os }}-${{ runner.arch }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-gradle-
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild:tv run: bun run prebuild:tv
@@ -229,19 +233,33 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9.0.0
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -259,19 +277,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 0 EXPO_TV: 0
@@ -301,19 +306,26 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -331,12 +343,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 0 EXPO_TV: 0
@@ -368,19 +374,33 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9.0.0
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -398,19 +418,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 1 EXPO_TV: 1
@@ -438,19 +445,26 @@ jobs:
actions: write # dispatch artifact-comment.yml to refresh the PR comment actions: write # dispatch artifact-comment.yml to refresh the PR comment
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -468,12 +482,6 @@ jobs:
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.5"
- name: 🚀 Build iOS app - name: 🚀 Build iOS app
env: env:
EXPO_TV: 1 EXPO_TV: 1

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
validate_pr_title: validate_pr_title:
name: "📝 Validate PR Title" name: "📝 Validate PR Title"
@@ -46,6 +50,8 @@ jobs:
dependency-review: dependency-review:
name: 🔍 Vulnerable Dependencies name: 🔍 Vulnerable Dependencies
# PR title/body edits can't change the dependency graph — only re-run on code events.
if: github.event.action != 'edited'
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
permissions: permissions:
contents: read contents: read
@@ -65,20 +71,31 @@ jobs:
expo-doctor: expo-doctor:
name: 🚑 Expo Doctor Check name: 🚑 Expo Doctor Check
# PR title/body edits can't change the project — only re-run on code events.
if: github.event.action != 'edited'
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
steps: steps:
- name: 🛒 Checkout repository - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 🛒 Checkout repository
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
submodules: recursive ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
# renovate: datasource=npm depName=bun
bun-version: "1.3.14"
- name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
# renovate: datasource=npm depName=bun path: ~/.bun/install/cache
bun-version: "1.3.14" key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: 📦 Install dependencies (bun) - name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -91,6 +108,8 @@ jobs:
code_quality: code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})" name: "🔍 Lint & Test (${{ matrix.command }})"
# PR title/body edits can't change the code — only re-run on code events.
if: github.event.action != 'edited'
runs-on: ubuntu-26.04 runs-on: ubuntu-26.04
strategy: strategy:
fail-fast: false fail-fast: false
@@ -103,24 +122,27 @@ jobs:
- "i18n:check" - "i18n:check"
steps: steps:
- name: "📥 Checkout PR code" - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: "📥 Checkout PR code"
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
ref: ${{ github.event.pull_request.head.sha || github.sha }} with:
submodules: recursive ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0 submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🍞 Setup Bun"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=node-version depName=node versioning=node # renovate: datasource=npm depName=bun
node-version: "24.18.0" bun-version: "1.3.14"
- name: "🍞 Setup Bun" - name: 💾 Cache Bun dependencies
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with: with:
# renovate: datasource=npm depName=bun path: ~/.bun/install/cache
bun-version: "1.3.14" key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-
- name: "📦 Install dependencies" - name: "📦 Install dependencies"
run: bun install --frozen-lockfile run: bun install --frozen-lockfile

View File

@@ -13,6 +13,8 @@ name: 🚀 Release (EAS build + submit)
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
# Queue successive releases in order instead of dropping the extra pending run.
queue: max
on: on:
push: push:
@@ -63,18 +65,26 @@ jobs:
artifact_name: streamyfin-android-tv-apk artifact_name: streamyfin-android-tv-apk
steps: steps:
- name: 📥 Checkout code - parallel:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: 📥 Checkout code
with: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
fetch-depth: 0 with:
submodules: recursive fetch-depth: 0
show-progress: false submodules: recursive
show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
# renovate: datasource=npm depName=bun # renovate: datasource=npm depName=bun
bun-version: "1.3.14" bun-version: "1.3.14"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9.0.0
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
@@ -89,13 +99,6 @@ jobs:
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses credentialsSource: local — restore the gitignored # tvOS uses credentialsSource: local — restore the gitignored
# credentials.json + cert + provisioning profiles from secrets. # credentials.json + cert + provisioning profiles from secrets.
- name: 🔐 Restore tvOS signing credentials - name: 🔐 Restore tvOS signing credentials

View File

@@ -1,24 +1,14 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, RefreshControl, ScrollView, View } from "react-native"; import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FavoritesTabButtons } from "@/components/favorites/FavoritesTabButtons";
import { Favorites } from "@/components/home/Favorites"; import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv"; import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useSettings } from "@/utils/atoms/settings";
export default function FavoritesPage() { export default function FavoritesPage() {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const { t } = useTranslation();
const { settings } = useSettings();
const [loading, setLoading] = useState(false); 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 () => { const refetch = useCallback(async () => {
setLoading(true); setLoading(true);
await invalidateCache(); await invalidateCache();
@@ -30,8 +20,6 @@ export default function FavoritesPage() {
return <TVFavorites />; return <TVFavorites />;
} }
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
@@ -46,26 +34,7 @@ export default function FavoritesPage() {
}} }}
> >
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}> <View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
{watchlistEnabled && ( <Favorites />
<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> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemKind, BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
@@ -11,7 +10,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Platform, useWindowDimensions, View } from "react-native"; import { useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -53,13 +52,9 @@ export default function FavoritesSeeAllScreen() {
const searchParams = useLocalSearchParams<{ const searchParams = useLocalSearchParams<{
type?: string; type?: string;
title?: string; title?: string;
filter?: string;
}>(); }>();
const typeParam = searchParams.type; const typeParam = searchParams.type;
const titleParam = searchParams.title; const titleParam = searchParams.title;
// Watchlist (KefinTweaks) reuses this screen with the "Likes" filter.
const filter: ItemFilter =
searchParams.filter === "Likes" ? "Likes" : "IsFavorite";
const itemType = useMemo(() => { const itemType = useMemo(() => {
if (!isFavoriteType(typeParam)) return null; if (!isFavoriteType(typeParam)) return null;
@@ -82,7 +77,7 @@ export default function FavoritesSeeAllScreen() {
userId: user.Id, userId: user.Id,
sortBy: ["SeriesSortName", "SortName"], sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"], sortOrder: ["Ascending"],
filters: [filter], filters: ["IsFavorite"],
recursive: true, recursive: true,
fields: ["PrimaryImageAspectRatio"], fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false, collapseBoxSetItems: false,
@@ -95,12 +90,12 @@ export default function FavoritesSeeAllScreen() {
return response.data.Items || []; return response.data.Items || [];
}, },
[api, itemType, user?.Id, filter], [api, itemType, user?.Id],
); );
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["favorites", "see-all", itemType, filter], queryKey: ["favorites", "see-all", itemType],
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }), queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined; if (!lastPage || lastPage.length < pageSize) return undefined;
@@ -160,7 +155,7 @@ export default function FavoritesSeeAllScreen() {
options={{ options={{
headerTitle: headerTitle, headerTitle: headerTitle,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: true,
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />

View File

@@ -50,7 +50,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() { export default function SettingsTV() {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin(); const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -877,15 +877,6 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} 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 */} {/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} /> <TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton

View File

@@ -9,7 +9,6 @@ import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { AddToFavorites } from "@/components/AddToFavorites"; import { AddToFavorites } from "@/components/AddToFavorites";
import { AddToKefinWatchlist } from "@/components/AddToKefinWatchlist";
import { DownloadItems } from "@/components/DownloadItem"; import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
@@ -19,7 +18,6 @@ import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import { import {
buildOfflineSeriesFromEpisodes, buildOfflineSeriesFromEpisodes,
getDownloadedEpisodesForSeries, getDownloadedEpisodesForSeries,
@@ -32,7 +30,6 @@ import { storage } from "@/utils/mmkv";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { const {
id: seriesId, id: seriesId,
@@ -140,7 +137,6 @@ const page: React.FC = () => {
!isLoading && item && allEpisodes && allEpisodes.length > 0 ? ( !isLoading && item && allEpisodes && allEpisodes.length > 0 ? (
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{settings?.useKefinTweaks && <AddToKefinWatchlist item={item} />}
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadItems <DownloadItems
size='large' size='large'
@@ -161,7 +157,7 @@ const page: React.FC = () => {
</View> </View>
) : null, ) : null,
}); });
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]); }, [allEpisodes, isLoading, item, isOffline]);
// For offline mode, we can show the page even without backdropUrl // For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null; if (!item || (!isOffline && !backdropUrl)) return null;

View File

@@ -50,9 +50,7 @@ function TVTabLayout() {
const router = useRouter(); const router = useRouter();
const currentTab = segments.find(isTabRoute); const currentTab = segments.find(isTabRoute);
// Widened to string: typed routes omit "index" segments, but they can still const lastSegment = segments[segments.length - 1] ?? "";
// appear at runtime, so keep the guard without tripping TS2367.
const lastSegment: string = segments[segments.length - 1] ?? "";
const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index"; const atTabRoot = isTabRoute(lastSegment) || lastSegment === "index";
const tabs: TVNavBarTab[] = useMemo( const tabs: TVNavBarTab[] = useMemo(

View File

@@ -1,29 +0,0 @@
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>
);
};

View File

@@ -29,7 +29,6 @@ import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { AddToFavorites } from "./AddToFavorites"; import { AddToFavorites } from "./AddToFavorites";
import { AddToKefinWatchlist } from "./AddToKefinWatchlist";
import { AddToWatchlist } from "./AddToWatchlist"; import { AddToWatchlist } from "./AddToWatchlist";
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
@@ -139,9 +138,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
<PlayedStatus items={[item]} size='large' /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{settings.useKefinTweaks && (
<AddToKefinWatchlist item={item} />
)}
{settings.streamyStatsServerUrl && {settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && ( !settings.hideWatchlistsTab && (
<AddToWatchlist item={item} /> <AddToWatchlist item={item} />
@@ -164,9 +160,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
<PlayedStatus items={[item]} size='large' /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
{settings.useKefinTweaks && (
<AddToKefinWatchlist item={item} />
)}
{settings.streamyStatsServerUrl && {settings.streamyStatsServerUrl &&
!settings.hideWatchlistsTab && ( !settings.hideWatchlistsTab && (
<AddToWatchlist item={item} /> <AddToWatchlist item={item} />
@@ -185,7 +178,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
settings.hideRemoteSessionButton, settings.hideRemoteSessionButton,
settings.streamyStatsServerUrl, settings.streamyStatsServerUrl,
settings.hideWatchlistsTab, settings.hideWatchlistsTab,
settings.useKefinTweaks,
]); ]);
useEffect(() => { useEffect(() => {

View File

@@ -39,7 +39,6 @@ import {
TVRefreshButton, TVRefreshButton,
TVSeriesNavigation, TVSeriesNavigation,
TVTechnicalDetails, TVTechnicalDetails,
TVWatchlistButton,
} from "@/components/tv"; } from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types"; import type { Track } from "@/components/video-player/controls/types";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
@@ -753,7 +752,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text> </Text>
</TVButton> </TVButton>
<TVFavoriteButton item={item} /> <TVFavoriteButton item={item} />
{settings.useKefinTweaks && <TVWatchlistButton item={item} />}
<TVPlayedButton item={item} /> <TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} /> <TVRefreshButton itemId={item.Id} />
</View> </View>

View File

@@ -13,7 +13,6 @@ interface Props extends ViewProps {
fillColor?: "primary"; fillColor?: "primary";
color?: "white" | "purple"; color?: "white" | "purple";
hapticFeedback?: boolean; hapticFeedback?: boolean;
disabled?: boolean;
} }
export const RoundButton: React.FC<PropsWithChildren<Props>> = ({ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
@@ -25,7 +24,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
fillColor, fillColor,
color = "white", color = "white",
hapticFeedback = true, hapticFeedback = true,
disabled = false,
...viewProps ...viewProps
}) => { }) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9"; const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
@@ -33,7 +31,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const handlePress = () => { const handlePress = () => {
if (disabled) return;
if (hapticFeedback) { if (hapticFeedback) {
lightHapticFeedback(); lightHapticFeedback();
} }
@@ -44,8 +41,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return ( return (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
disabled={disabled} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
@@ -64,8 +60,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return ( return (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
disabled={disabled} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
@@ -83,8 +78,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return ( return (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
disabled={disabled} className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`}
{...(viewProps as any)} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
@@ -102,10 +96,9 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
return ( return (
<Pressable <Pressable
onPress={handlePress} onPress={handlePress}
disabled={disabled}
className={`rounded-full ${buttonSize} flex items-center justify-center ${ className={`rounded-full ${buttonSize} flex items-center justify-center ${
fillColor ? fillColorClass : "bg-transparent" fillColor ? fillColorClass : "bg-transparent"
} ${disabled ? "opacity-50" : ""}`} }`}
{...(viewProps as any)} {...(viewProps as any)}
> >
{icon ? ( {icon ? (
@@ -120,14 +113,10 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
); );
return ( return (
<Pressable <Pressable onPress={handlePress} {...(viewProps as any)}>
onPress={handlePress}
disabled={disabled}
{...(viewProps as any)}
>
<BlurView <BlurView
intensity={90} intensity={90}
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass} ${disabled ? "opacity-50" : ""}`} className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)} {...(viewProps as any)}
> >
{icon ? ( {icon ? (

View File

@@ -11,10 +11,8 @@ import {
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useWatchlist } from "@/hooks/useWatchlist";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -157,8 +155,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const { isWatchlisted, toggleWatchlist } = useWatchlist(item);
const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
@@ -187,66 +183,36 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
) )
return; return;
// Build options as { label, action } so dynamic entries (watchlist, const options: string[] = [
// offline delete) don't break index-based handling. t("common.mark_as_played"),
const actions: { t("common.mark_as_not_played"),
label: string; isFavorite
action: () => void; ? t("music.track_options.remove_from_favorites")
destructive?: boolean; : t("music.track_options.add_to_favorites"),
}[] = [ ...(isOffline ? [t("home.downloads.delete_download")] : []),
{ t("common.cancel"),
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 cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = actions.findIndex((a) => a.destructive); const destructiveButtonIndex = isOffline
? cancelButtonIndex - 1
: undefined;
showActionSheetWithOptions( showActionSheetWithOptions(
{ {
options, options,
cancelButtonIndex, cancelButtonIndex,
destructiveButtonIndex: destructiveButtonIndex,
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
}, },
(selectedIndex) => { async (selectedIndex) => {
if (selectedIndex === undefined || selectedIndex >= actions.length) if (selectedIndex === 0) {
return; await markAsPlayedStatus(true);
actions[selectedIndex].action(); } else if (selectedIndex === 1) {
await markAsPlayedStatus(false);
} else if (selectedIndex === 2) {
toggleFavorite();
} else if (isOffline && selectedIndex === 3 && item.Id) {
deleteFile(item.Id);
}
}, },
); );
}, [ }, [
@@ -254,9 +220,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
isFavorite, isFavorite,
markAsPlayedStatus, markAsPlayedStatus,
toggleFavorite, toggleFavorite,
isWatchlisted,
toggleWatchlist,
settings?.useKefinTweaks,
isOffline, isOffline,
deleteFile, deleteFile,
item.Id, item.Id,

View File

@@ -1,74 +0,0 @@
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>
);
};

View File

@@ -1,117 +0,0 @@
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>
);
};

View File

@@ -1,13 +1,10 @@
import type { Api } from "@jellyfin/sdk"; import type { Api } from "@jellyfin/sdk";
import type { import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
// PNG ASSET // PNG ASSET
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
@@ -23,39 +20,20 @@ type FavoriteTypes =
| "Video" | "Video"
| "BoxSet" | "BoxSet"
| "Playlist"; | "Playlist";
// `null` = not settled yet (loading/unknown); avoids flashing the empty type EmptyState = Record<FavoriteTypes, boolean>;
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
interface FavoritesProps { export const Favorites = () => {
/** 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 router = useRouter();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const pageSize = 20; const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({ const [emptyState, setEmptyState] = useState<EmptyState>({
Series: null, Series: false,
Movie: null, Movie: false,
Episode: null, Episode: false,
Video: null, Video: false,
BoxSet: null, BoxSet: false,
Playlist: null, Playlist: false,
}); });
const fetchFavoritesByType = useCallback( const fetchFavoritesByType = useCallback(
@@ -68,7 +46,7 @@ export const Favorites = ({
userId: user?.Id, userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"], sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"], sortOrder: ["Ascending"],
filters: [filter], filters: ["IsFavorite"],
recursive: true, recursive: true,
fields: ["PrimaryImageAspectRatio"], fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false, collapseBoxSetItems: false,
@@ -78,28 +56,39 @@ export const Favorites = ({
limit: limit, limit: limit,
includeItemTypes: [itemType], includeItemTypes: [itemType],
}); });
return response.data.Items || []; 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;
}, },
[api, user, filter], [api, user],
); );
// Emptiness is reported by each list once its query settles (incl. cache // Reset empty state when component mounts or dependencies change
// hits), so it stays correct where a queryFn side effect would go stale. useEffect(() => {
const setTypeEmpty = useCallback( setEmptyState({
(type: FavoriteTypes, isEmpty: boolean | null) => Series: false,
setEmptyState((prev) => Movie: false,
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty }, Episode: false,
), Video: false,
[], BoxSet: false,
); Playlist: false,
});
}, [api, user]);
// Show the empty message only once every category has settled AND is empty. // Check if all categories that have been loaded are 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 areAllEmpty = () => {
const categories = Object.values(emptyState); const loadedCategories = Object.values(emptyState);
return ( return (
categories.length > 0 && categories.every((isEmpty) => isEmpty === true) loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
); );
}; };
@@ -134,26 +123,47 @@ export const Favorites = ({
[fetchFavoritesByType, pageSize], [fetchFavoritesByType, pageSize],
); );
// Navigate to the shared see-all screen. `name` is the capitalized type const handleSeeAllSeries = useCallback(() => {
// suffix of the see-all header key (e.g. "Series" -> "seeAllSeries"). router.push({
// The namespace is branched explicitly so each t() call has a static prefix pathname: "/(auth)/(tabs)/(favorites)/see-all",
// (favorites.seeAll* / kefintweaksWatchlist.seeAll*) that the i18n usage params: { type: "Series", title: t("favorites.series") },
// checker can detect — see scripts/check-i18n-keys.mjs. The `as any` is } as any);
// needed because the route's custom params aren't part of expo-router's }, [router]);
// typed Href.
const seeAll = useCallback( const handleSeeAllMovies = useCallback(() => {
(type: FavoriteTypes, name: string) => { router.push({
const title = pathname: "/(auth)/(tabs)/(favorites)/see-all",
seeAllNamespace === "kefintweaksWatchlist" params: { type: "Movie", title: t("favorites.movies") },
? t(`kefintweaksWatchlist.seeAll${name}`) } as any);
: t(`favorites.seeAll${name}`); }, [router]);
router.push({
pathname: "/(auth)/(tabs)/(favorites)/see-all", const handleSeeAllEpisodes = useCallback(() => {
params: { type, title, filter }, router.push({
} as any); pathname: "/(auth)/(tabs)/(favorites)/see-all",
}, params: { type: "Episode", title: t("favorites.episodes") },
[router, filter, seeAllNamespace], } 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]);
return ( return (
<View className='flex flex-co gap-y-4'> <View className='flex flex-co gap-y-4'>
@@ -166,67 +176,61 @@ export const Favorites = ({
source={heart} source={heart}
/> />
<Text className='text-xl font-semibold text-white mb-2'> <Text className='text-xl font-semibold text-white mb-2'>
{t(emptyTitleKey)} {t("favorites.noDataTitle")}
</Text> </Text>
<Text className='text-base text-white/70 text-center max-w-xs px-4'> <Text className='text-base text-white/70 text-center max-w-xs px-4'>
{t(emptyTextKey)} {t("favorites.noData")}
</Text> </Text>
</View> </View>
)} )}
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries} queryFn={fetchFavoriteSeries}
queryKey={["home", queryKeyBase, "series"]} queryKey={["home", "favorites", "series"]}
title={t("favorites.series")} title={t("favorites.series")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)} onPressSeeAll={handleSeeAllSeries}
onPressSeeAll={() => seeAll("Series", "Series")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
queryKey={["home", queryKeyBase, "movies"]} queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")} title={t("favorites.movies")}
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)} onPressSeeAll={handleSeeAllMovies}
onPressSeeAll={() => seeAll("Movie", "Movies")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
queryKey={["home", queryKeyBase, "episodes"]} queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)} onPressSeeAll={handleSeeAllEpisodes}
onPressSeeAll={() => seeAll("Episode", "Episodes")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
queryKey={["home", queryKeyBase, "videos"]} queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)} onPressSeeAll={handleSeeAllVideos}
onPressSeeAll={() => seeAll("Video", "Videos")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
queryKey={["home", queryKeyBase, "boxsets"]} queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)} onPressSeeAll={handleSeeAllBoxsets}
onPressSeeAll={() => seeAll("BoxSet", "Boxsets")}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
queryKey={["home", queryKeyBase, "playlists"]} queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)} onPressSeeAll={handleSeeAllPlaylists}
onPressSeeAll={() => seeAll("Playlist", "Playlists")}
/> />
</View> </View>
); );

View File

@@ -1,23 +1,18 @@
import type { Api } from "@jellyfin/sdk"; import type { Api } from "@jellyfin/sdk";
import type { import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
BaseItemKind,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVFavoritesTabBadges } from "@/components/favorites/TVFavoritesTabBadges";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv"; import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
const HORIZONTAL_PADDING = 60; const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100; const TOP_PADDING = 100;
@@ -30,9 +25,7 @@ type FavoriteTypes =
| "Video" | "Video"
| "BoxSet" | "BoxSet"
| "Playlist"; | "Playlist";
// `null` = not settled yet (loading/unknown); avoids flashing the empty type EmptyState = Record<FavoriteTypes, boolean>;
// message during a favorites/watchlist switch before the new queries resolve.
type EmptyState = Record<FavoriteTypes, boolean | null>;
export const Favorites = () => { export const Favorites = () => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
@@ -40,34 +33,14 @@ export const Favorites = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { settings } = useSettings();
const pageSize = 20; 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>({ const [emptyState, setEmptyState] = useState<EmptyState>({
Series: null, Series: false,
Movie: null, Movie: false,
Episode: null, Episode: false,
Video: null, Video: false,
BoxSet: null, BoxSet: false,
Playlist: null, Playlist: false,
}); });
const fetchFavoritesByType = useCallback( const fetchFavoritesByType = useCallback(
@@ -80,7 +53,7 @@ export const Favorites = () => {
userId: user?.Id, userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"], sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"], sortOrder: ["Ascending"],
filters: [filter], filters: ["IsFavorite"],
recursive: true, recursive: true,
fields: ["PrimaryImageAspectRatio"], fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false, collapseBoxSetItems: false,
@@ -90,28 +63,36 @@ export const Favorites = () => {
limit: limit, limit: limit,
includeItemTypes: [itemType], includeItemTypes: [itemType],
}); });
return response.data.Items || []; const items = response.data.Items || [];
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
}, },
[api, user, filter], [api, user],
); );
// Emptiness is reported by each list once its query settles (incl. cache useEffect(() => {
// hits), so it stays correct where a queryFn side effect would go stale. setEmptyState({
const setTypeEmpty = useCallback( Series: false,
(type: FavoriteTypes, isEmpty: boolean | null) => Movie: false,
setEmptyState((prev) => Episode: false,
prev[type] === isEmpty ? prev : { ...prev, [type]: isEmpty }, Video: false,
), BoxSet: false,
[], Playlist: false,
); });
}, [api, user]);
// 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 areAllEmpty = () => {
const categories = Object.values(emptyState); const loadedCategories = Object.values(emptyState);
return ( return (
categories.length > 0 && categories.every((isEmpty) => isEmpty === true) loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
); );
}; };
@@ -146,14 +127,49 @@ export const Favorites = () => {
[fetchFavoritesByType, pageSize], [fetchFavoritesByType, pageSize],
); );
const tabBadges = ( if (areAllEmpty()) {
<TVFavoritesTabBadges return (
viewType={viewType} <View
setViewType={setViewType} style={{
enabled={watchlistEnabled} flex: 1,
hasTVPreferredFocus={watchlistEnabled} 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>
);
}
return ( return (
<ScrollView <ScrollView
@@ -162,108 +178,52 @@ export const Favorites = () => {
contentContainerStyle={{ contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60, paddingBottom: insets.bottom + 60,
flexGrow: 1,
}} }}
> >
<View style={{ gap: SECTION_GAP, flex: 1 }}> <View style={{ gap: SECTION_GAP }}>
{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 <InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries} queryFn={fetchFavoriteSeries}
queryKey={["home", queryKeyBase, "series"]} queryKey={["home", "favorites", "series"]}
title={t("favorites.series")} title={t("favorites.series")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
isFirstSection={!watchlistEnabled} isFirstSection
onEmptyStateChange={(isEmpty) => setTypeEmpty("Series", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
queryKey={["home", queryKeyBase, "movies"]} queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")} title={t("favorites.movies")}
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Movie", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
queryKey={["home", queryKeyBase, "episodes"]} queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Episode", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
queryKey={["home", queryKeyBase, "videos"]} queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Video", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
queryKey={["home", queryKeyBase, "boxsets"]} queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("BoxSet", isEmpty)}
/> />
<InfiniteScrollingCollectionList <InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
queryKey={["home", queryKeyBase, "playlists"]} queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")} title={t("favorites.playlists")}
hideIfEmpty hideIfEmpty
pageSize={pageSize} pageSize={pageSize}
onEmptyStateChange={(isEmpty) => setTypeEmpty("Playlist", isEmpty)}
/> />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -32,13 +32,6 @@ interface Props extends ViewProps {
onPressSeeAll?: () => void; onPressSeeAll?: () => void;
enabled?: boolean; enabled?: boolean;
onLoaded?: () => void; 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> = ({ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
@@ -52,7 +45,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
onPressSeeAll, onPressSeeAll,
enabled = true, enabled = true,
onLoaded, onLoaded,
onEmptyStateChange,
...props ...props
}) => { }) => {
const effectivePageSize = Math.max(1, pageSize); const effectivePageSize = Math.max(1, pageSize);
@@ -60,7 +52,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const { const {
data, data,
isLoading, isLoading,
isError,
isFetchingNextPage, isFetchingNextPage,
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
@@ -112,17 +103,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped; return deduped;
}, [data]); }, [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 snapOffsets = useMemo(() => {
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px) 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); return allItems.map((_, index) => index * itemWidth);

View File

@@ -6,7 +6,7 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useSegments } from "expo-router"; import { useSegments } from "expo-router";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
@@ -42,13 +42,6 @@ interface Props extends ViewProps {
isFirstSection?: boolean; isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void; onItemFocus?: (item: BaseItemDto) => void;
parentId?: string; 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>; type Typography = ReturnType<typeof useScaledTVTypography>;
@@ -130,7 +123,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection = false, isFirstSection = false,
onItemFocus, onItemFocus,
parentId, parentId,
onEmptyStateChange,
...props ...props
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
@@ -153,30 +145,24 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
[onItemFocus], [onItemFocus],
); );
const { const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
data, useInfiniteQuery({
isLoading, queryKey: queryKey,
isError, queryFn: ({ pageParam = 0, ...context }) =>
isFetchingNextPage, queryFn({ ...context, queryKey, pageParam }),
hasNextPage, getNextPageParam: (lastPage, allPages) => {
fetchNextPage, if (lastPage.length < effectivePageSize) {
} = useInfiniteQuery({ return undefined;
queryKey: queryKey, }
queryFn: ({ pageParam = 0, ...context }) => return allPages.reduce((acc, page) => acc + page.length, 0);
queryFn({ ...context, queryKey, pageParam }), },
getNextPageParam: (lastPage, allPages) => { initialPageParam: 0,
if (lastPage.length < effectivePageSize) { staleTime: 60 * 1000,
return undefined; refetchInterval: 60 * 1000,
} refetchOnWindowFocus: false,
return allPages.reduce((acc, page) => acc + page.length, 0); refetchOnReconnect: true,
}, enabled,
initialPageParam: 0, });
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
const { t } = useTranslation(); const { t } = useTranslation();
@@ -196,17 +182,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
return deduped; return deduped;
}, [data]); }, [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 = const itemWidth =
orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; orientation === "horizontal" ? posterSizes.episode : posterSizes.poster;

View File

@@ -1,36 +0,0 @@
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>
);
};

View File

@@ -70,5 +70,3 @@ export { TVTrackCard } from "./TVTrackCard";
// User switching // User switching
export type { TVUserCardProps } from "./TVUserCard"; export type { TVUserCardProps } from "./TVUserCard";
export { TVUserCard } from "./TVUserCard"; export { TVUserCard } from "./TVUserCard";
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
export { TVWatchlistButton } from "./TVWatchlistButton";

View File

@@ -1,156 +0,0 @@
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,
};
};

View File

@@ -588,25 +588,8 @@
"videos": "Videos", "videos": "Videos",
"boxsets": "Box sets", "boxsets": "Box sets",
"playlists": "Playlists", "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", "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": { "custom_links": {
"no_links": "No links" "no_links": "No links"

View File

@@ -82,6 +82,8 @@ export const useFilterOptions = () => {
{ key: FilterByOption.IsFavorite, value: "Is Favorite" }, { key: FilterByOption.IsFavorite, value: "Is Favorite" },
{ key: FilterByOption.IsResumable, value: "Is Resumable" }, { key: FilterByOption.IsResumable, value: "Is Resumable" },
]; ];
console.log("filterOptions");
console.log(filterOptions);
return filterOptions; return filterOptions;
}; };