Compare commits
3 Commits
feat/kefin
...
fix/contro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f99400876b | ||
|
|
de5e323db4 | ||
|
|
fef1e7f122 |
@@ -1,25 +0,0 @@
|
||||
# Custom EAS Build config for Android phone APK (downloadable artifact).
|
||||
# Same bun-forcing flow as android-production.yml, but builds an APK
|
||||
# (assembleRelease) instead of an AAB — for sideloading / GitHub artifact.
|
||||
# Referenced from eas.json: build.production-apk.android.config
|
||||
build:
|
||||
name: Android phone APK (bun)
|
||||
steps:
|
||||
- eas/checkout
|
||||
|
||||
- run:
|
||||
name: Install dependencies (bun, frozen)
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
- run:
|
||||
name: Prebuild (Android, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
- eas/run_gradle:
|
||||
inputs:
|
||||
command: :app:assembleRelease
|
||||
|
||||
- eas/find_and_upload_build_artifacts
|
||||
@@ -1,27 +0,0 @@
|
||||
# Custom EAS Build config for Android TV APK (downloadable artifact).
|
||||
# Same bun-forcing flow, with EXPO_TV=1 (set via the profile env in
|
||||
# eas.json) so prebuild generates the TV variant. Builds an APK for
|
||||
# sideloading onto Android TV devices.
|
||||
# Referenced from eas.json: build.production-apk-tv.android.config
|
||||
build:
|
||||
name: Android TV APK (bun)
|
||||
steps:
|
||||
- eas/checkout
|
||||
|
||||
- run:
|
||||
name: Install dependencies (bun, frozen)
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
# EXPO_TV=1 comes from the profile env, so prebuild targets Android TV.
|
||||
- run:
|
||||
name: Prebuild (Android TV, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
|
||||
- eas/configure_android_version
|
||||
- eas/inject_android_credentials
|
||||
|
||||
- eas/run_gradle:
|
||||
inputs:
|
||||
command: :app:assembleRelease
|
||||
|
||||
- eas/find_and_upload_build_artifacts
|
||||
@@ -1,38 +0,0 @@
|
||||
# Custom EAS Build config for Android (production AAB).
|
||||
#
|
||||
# Why this exists: EAS's managed build can't detect Bun's text lockfile
|
||||
# (bun.lock) and falls back to yarn, which breaks our install. The managed
|
||||
# steps `eas/install_node_modules` and `eas/prebuild` both use "the package
|
||||
# manager detected based on your project", so we replace them with explicit
|
||||
# `bun` commands. Everything else uses EAS's built-in functions so we still
|
||||
# get remote versioning, credentials, and artifact upload.
|
||||
#
|
||||
# Referenced from eas.json: build.production.android.config = android-production.yml
|
||||
build:
|
||||
name: Android production (bun)
|
||||
steps:
|
||||
- eas/checkout
|
||||
|
||||
- run:
|
||||
name: Install dependencies (bun, frozen)
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
# android/ is gitignored, so generate native code fresh. --no-install
|
||||
# because deps are already installed above; bunx keeps it on bun.
|
||||
- run:
|
||||
name: Prebuild (Android, bun)
|
||||
command: bunx expo prebuild --platform android --no-install
|
||||
|
||||
# Applies the EAS-resolved remote versionCode/versionName (autoIncrement
|
||||
# in eas.json) into the freshly prebuilt android/ project.
|
||||
- eas/configure_android_version
|
||||
|
||||
# Injects the remote Android keystore / signing config.
|
||||
- eas/inject_android_credentials
|
||||
|
||||
# Build the Play Store app bundle (.aab).
|
||||
- eas/run_gradle:
|
||||
inputs:
|
||||
command: :app:bundleRelease
|
||||
|
||||
- eas/find_and_upload_build_artifacts
|
||||
@@ -1,44 +0,0 @@
|
||||
# Custom EAS Build config for iOS + tvOS (App Store), forcing bun.
|
||||
#
|
||||
# Shared by both the iPhone profile (production) and the tvOS profile
|
||||
# (production_tv). The profile decides the rest:
|
||||
# - production_tv sets EXPO_TV=1 (env) so prebuild targets tvOS, and
|
||||
# credentialsSource: local (EAS can't manage tvOS creds remotely).
|
||||
# - production uses remote-managed iOS credentials.
|
||||
#
|
||||
# Like the Android configs, this replaces eas/install_node_modules and
|
||||
# eas/prebuild (both auto-detect the wrong package manager) with explicit
|
||||
# bun commands, and keeps EAS built-ins for credentials/version/fastlane.
|
||||
build:
|
||||
name: iOS/tvOS App Store (bun)
|
||||
steps:
|
||||
- eas/checkout
|
||||
|
||||
- run:
|
||||
name: Install dependencies (bun, frozen)
|
||||
command: bun install --frozen-lockfile
|
||||
|
||||
- eas/resolve_apple_team_id_from_credentials:
|
||||
id: resolve_team
|
||||
|
||||
# android/ + ios/ are gitignored, so generate native code fresh.
|
||||
# EXPO_TV (from the profile env) selects iPhone vs tvOS. --no-install
|
||||
# skips JS + pod install; we install pods explicitly below with bun deps.
|
||||
- run:
|
||||
name: Prebuild (iOS/tvOS, bun)
|
||||
command: bunx expo prebuild --platform ios --no-install
|
||||
|
||||
- run:
|
||||
name: Install CocoaPods
|
||||
working_directory: ./ios
|
||||
command: pod install
|
||||
|
||||
- eas/configure_ios_credentials
|
||||
- eas/configure_ios_version
|
||||
|
||||
- eas/generate_gymfile_from_template:
|
||||
inputs:
|
||||
credentials: ${ eas.job.secrets.buildCredentials }
|
||||
|
||||
- eas/run_fastlane
|
||||
- eas/find_and_upload_build_artifacts
|
||||
29
.gitattributes
vendored
@@ -1,28 +1 @@
|
||||
# Normalise line endings to LF for everyone. Files are stored as LF in git and
|
||||
# checked out as LF on every OS, so Windows clones stop producing CRLF churn
|
||||
# (no more "LF will be replaced by CRLF" warnings) regardless of core.autocrlf.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows-only scripts must stay CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binary assets — never touched / never normalised
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.mp3 binary
|
||||
*.mp4 binary
|
||||
*.mov binary
|
||||
*.pdf binary
|
||||
*.keystore binary
|
||||
*.jks binary
|
||||
*.p12 binary
|
||||
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
22
.github/ISSUE_TEMPLATE/issue_report.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: Create a report to help Streamyfin improve
|
||||
description: Create a report to help us improve
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- "🐛 bug"
|
||||
@@ -36,7 +36,7 @@ body:
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Describe what happened in detail, the more precise the better.
|
||||
placeholder: Describe what happened in detail.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -67,7 +67,7 @@ body:
|
||||
attributes:
|
||||
label: Which device and operating system are you using?
|
||||
description: Please provide your device model and OS version
|
||||
placeholder: e.g. iPhone 17 Pro / iOS 26.5.1, Samsung Galaxy S25 / Android 16, Apple TV / tvOS 26.5
|
||||
placeholder: e.g. iPhone 15 Pro, iOS 18.1.1 or Samsung Galaxy S24, Android 14
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -75,11 +75,11 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: Streamyfin Version
|
||||
description: What version of Streamyfin are you using?
|
||||
description: What version of Streamyfin are you running?
|
||||
options:
|
||||
- 0.54.1
|
||||
- 0.51.0
|
||||
- Older
|
||||
- 0.47.1
|
||||
- 0.30.2
|
||||
- older
|
||||
- TestFlight/Development build
|
||||
validations:
|
||||
required: true
|
||||
@@ -90,9 +90,9 @@ body:
|
||||
label: Jellyfin Server Information
|
||||
description: Please provide details about your Jellyfin server
|
||||
placeholder: |
|
||||
- Jellyfin Server Version: e.g. 10.11.10
|
||||
- Server OS: e.g. Ubuntu 26.04, Windows 11, Docker, Proxmox
|
||||
- Connection: e.g. Local network, remote via domain, VPN
|
||||
- Jellyfin Server Version: e.g. 10.10.7
|
||||
- Server OS: e.g. Ubuntu 22.04, Windows 11, Docker
|
||||
- Connection: e.g. Local network, Remote via domain, VPN
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
@@ -104,7 +104,7 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs (if available)
|
||||
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URL, API keys or usernames.**
|
||||
description: If you have access to app logs or crash reports, please include them here. **Remember to remove any personal information like server URLs or usernames.**
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
|
||||
54
.github/renovate.json
vendored
@@ -44,42 +44,22 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Expo SDK coherence: expo, react, react-native and Expo-managed modules are pinned by the Expo SDK and must move together (via `expo install --fix`), so do not raise individual update PRs — group them and require manual approval from the Dependency Dashboard",
|
||||
"matchPackageNames": [
|
||||
"expo",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-native",
|
||||
"react-native-web",
|
||||
"expo-*",
|
||||
"@expo/*"
|
||||
],
|
||||
"groupName": "Expo SDK",
|
||||
"dependencyDashboardApproval": true
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
"addLabels": ["security", "vulnerability"],
|
||||
"assigneesFromCodeOwners": true,
|
||||
"commitMessageSuffix": " [SECURITY]"
|
||||
},
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "androidx and other Google-hosted Maven packages resolve from Google's Maven repository (not Maven Central)",
|
||||
"matchDatasources": ["maven"],
|
||||
"registryUrls": [
|
||||
"https://dl.google.com/dl/android/maven2/",
|
||||
"https://repo.maven.apache.org/maven2/"
|
||||
]
|
||||
}
|
||||
]
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group minor and patch GitHub Action updates into a single PR",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "CI dependencies",
|
||||
"groupSlug": "ci-deps",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
30
.github/workflows/build-apps.yml
vendored
@@ -11,12 +11,6 @@ on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
|
||||
# Exposed to `expo prebuild` (app.config.js → extra.build) so Settings can show the
|
||||
# branch + commit a CI build was made from. EAS cloud builds use EAS_BUILD_* instead.
|
||||
env:
|
||||
EXPO_PUBLIC_GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
EXPO_PUBLIC_GIT_COMMIT: ${{ github.sha }}
|
||||
|
||||
jobs:
|
||||
build-android-phone:
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
@@ -39,7 +33,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -122,7 +116,7 @@ jobs:
|
||||
swap-storage: false
|
||||
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -193,7 +187,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -225,10 +219,10 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -258,7 +252,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -290,7 +284,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -318,7 +312,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -350,10 +344,10 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
@@ -386,7 +380,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -418,7 +412,7 @@ jobs:
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.5"
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
|
||||
2
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
show-progress: false
|
||||
|
||||
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: 🏁 Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended,security-and-quality
|
||||
|
||||
- name: 🛠️ Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
- name: 🧪 Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
|
||||
102
.github/workflows/crowdin.yml
vendored
@@ -1,51 +1,51 @@
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
name: 🌐 Translation Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- "translations/**"
|
||||
- "crowdin.yml"
|
||||
- "i18n.ts"
|
||||
- ".github/workflows/crowdin.yml"
|
||||
# Run weekly to pull new translations
|
||||
schedule:
|
||||
- cron: "0 2 * * 1" # Every Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🌐 Sync Translations with Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: true
|
||||
localization_branch_name: I10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "feat: New Crowdin Translations"
|
||||
pull_request_body: "New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)"
|
||||
pull_request_base_branch_name: "develop"
|
||||
pull_request_labels: "🌐 translation"
|
||||
# Quality control options
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
export_only_approved: false
|
||||
# Commit customization
|
||||
commit_message: "feat(i18n): update translations from Crowdin"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
38
.github/workflows/detect-duplicate.yml
vendored
@@ -1,38 +0,0 @@
|
||||
name: 🔁 Detect Duplicate Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: detect-duplicate-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
name: 🔍 Find similar issues
|
||||
if: github.actor != 'github-actions[bot]'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: 🍞 Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 🔍 Detect duplicate issues
|
||||
run: bun scripts/detect-duplicate-issue.mjs
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
7
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: 🛒 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
@@ -97,11 +97,10 @@ jobs:
|
||||
- "check"
|
||||
- "format"
|
||||
- "typecheck"
|
||||
- "i18n:check"
|
||||
|
||||
steps:
|
||||
- name: "📥 Checkout PR code"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
|
||||
138
.github/workflows/release.yml
vendored
@@ -1,14 +1,9 @@
|
||||
name: 🚀 Release (EAS build + submit)
|
||||
name: 🚀 Release (EAS Build + Submit)
|
||||
|
||||
# On merge to main (gated by the `production` GitHub Environment approval),
|
||||
# build all targets on EAS in parallel via custom bun build configs:
|
||||
# 1. iOS phone → App Store (auto-submit)
|
||||
# 2. tvOS → App Store (auto-submit)
|
||||
# 3. Android AAB → Google Play (auto-submit)
|
||||
# 4. Android phone APK→ downloadable artifact
|
||||
# 5. Android TV APK → downloadable artifact
|
||||
# Note: EAS queues builds based on your plan's concurrency; parallel jobs
|
||||
# here just submit them — EAS may still run them serially.
|
||||
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main.
|
||||
# A manual approval gate (the `production` GitHub Environment) pauses the run
|
||||
# before any build/submit starts. Configure required reviewers on that
|
||||
# environment in repo Settings → Environments → production.
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -28,7 +23,7 @@ jobs:
|
||||
- name: ✅ Release approved
|
||||
run: echo "Release approved for ${{ github.sha }}"
|
||||
|
||||
build:
|
||||
release:
|
||||
name: 🚀 ${{ matrix.name }}
|
||||
needs: approve
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -41,29 +36,16 @@ jobs:
|
||||
- name: 🍎 iOS
|
||||
platform: ios
|
||||
profile: production
|
||||
submit: true
|
||||
- name: 📺 tvOS
|
||||
platform: ios
|
||||
profile: production_tv
|
||||
submit: true
|
||||
- name: 🤖 Android AAB
|
||||
- name: 🤖 Android
|
||||
platform: android
|
||||
profile: production
|
||||
submit: true
|
||||
- name: 🤖 Android APK
|
||||
platform: android
|
||||
profile: production-apk
|
||||
submit: false
|
||||
artifact_name: streamyfin-android-phone-apk
|
||||
- name: 📺 Android TV APK
|
||||
platform: android
|
||||
profile: production-apk-tv
|
||||
submit: false
|
||||
artifact_name: streamyfin-android-tv-apk
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
@@ -88,14 +70,16 @@ jobs:
|
||||
bun run submodule-reload
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # main
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # 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.
|
||||
# tvOS uses local credentials (EAS can't manage tvOS provisioning
|
||||
# remotely, including the TopShelf extension target). Restore the
|
||||
# gitignored credentials.json + cert + profiles from secrets so the
|
||||
# cloud build can sign with `credentialsSource: local`.
|
||||
- name: 🔐 Restore tvOS signing credentials
|
||||
if: matrix.profile == 'production_tv'
|
||||
env:
|
||||
@@ -110,14 +94,10 @@ jobs:
|
||||
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
|
||||
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
|
||||
|
||||
# Android Play submit needs the Google Play service account JSON.
|
||||
- name: 🔐 Restore Google Play service account
|
||||
if: matrix.platform == 'android' && matrix.submit
|
||||
env:
|
||||
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
|
||||
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
|
||||
|
||||
# App Store Connect API key for iOS/tvOS submit (raw-PEM or base64).
|
||||
# iOS + tvOS submit upload to App Store Connect with an ASC API key.
|
||||
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID /
|
||||
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8,
|
||||
# tolerating either raw-PEM or base64-encoded secret content.
|
||||
- name: 🔐 Restore App Store Connect API key
|
||||
if: matrix.platform == 'ios'
|
||||
env:
|
||||
@@ -129,11 +109,18 @@ jobs:
|
||||
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
|
||||
fi
|
||||
|
||||
# ── Submit builds: cloud build + auto-submit to the store ──
|
||||
# Android submit needs a Google Play service account JSON. eas.json's
|
||||
# submit.production.android.serviceAccountKeyPath points at this file.
|
||||
- name: 🔐 Restore Google Play service account
|
||||
if: matrix.platform == 'android'
|
||||
env:
|
||||
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
|
||||
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
|
||||
|
||||
- name: 🚀 Build & submit (${{ matrix.name }})
|
||||
if: matrix.submit
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
# Consumed by eas submit for iOS/tvOS; ignored for Android.
|
||||
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
|
||||
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
|
||||
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
|
||||
@@ -142,75 +129,4 @@ jobs:
|
||||
--platform ${{ matrix.platform }} \
|
||||
--profile ${{ matrix.profile }} \
|
||||
--auto-submit \
|
||||
--non-interactive \
|
||||
--wait
|
||||
|
||||
# ── Artifact builds: cloud build, then download + upload the APK ──
|
||||
- name: 🏗️ Build artifact (${{ matrix.name }})
|
||||
if: ${{ !matrix.submit }}
|
||||
env:
|
||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
||||
run: |
|
||||
eas build \
|
||||
--platform ${{ matrix.platform }} \
|
||||
--profile ${{ matrix.profile }} \
|
||||
--non-interactive \
|
||||
--wait \
|
||||
--json > build-result.json
|
||||
URL=$(node -e "const b=require('./build-result.json'); const x=Array.isArray(b)?b[0]:b; console.log(x.artifacts.applicationArchiveUrl)")
|
||||
echo "Downloading artifact: $URL"
|
||||
curl -fL "$URL" -o "${{ matrix.artifact_name }}.apk"
|
||||
|
||||
- name: 📤 Upload APK artifact (${{ matrix.name }})
|
||||
if: ${{ !matrix.submit }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: ${{ matrix.artifact_name }}.apk
|
||||
retention-days: 14
|
||||
|
||||
# Draft a GitHub Release with the two APKs attached. The tag comes from the
|
||||
# merged-in app version (app.json → expo.version), NOT the auto-incremented
|
||||
# build number — so cutting a release is a deliberate version bump via PR.
|
||||
github-release:
|
||||
name: 📦 Draft GitHub Release
|
||||
needs: build
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read # required for `gh run download` to list/fetch this run's artifacts
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
show-progress: false
|
||||
|
||||
- name: 📦 Download APK artifacts from this run
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p apks
|
||||
gh run download ${{ github.run_id }} --name streamyfin-android-phone-apk --dir apks
|
||||
gh run download ${{ github.run_id }} --name streamyfin-android-tv-apk --dir apks
|
||||
ls -la apks
|
||||
|
||||
- name: 📝 Draft release (tag = app.json version, not auto-bumped)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=$(node -e "console.log(require('./app.json').expo.version)")
|
||||
TAG="v$VERSION"
|
||||
echo "Release tag from merged app version: $TAG"
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG exists — updating APK assets"
|
||||
gh release upload "$TAG" apks/*.apk --clobber
|
||||
else
|
||||
echo "Creating draft release $TAG"
|
||||
gh release create "$TAG" \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--title "$TAG" \
|
||||
apks/*.apk
|
||||
fi
|
||||
--non-interactive
|
||||
|
||||
60
.github/workflows/trivy-scan.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: 🛡️ Trivy Security Scan
|
||||
|
||||
# Filesystem scan (Streamyfin ships no container image): finds vulnerable dependencies,
|
||||
# leaked secrets and misconfigurations, and reports them to GitHub code scanning.
|
||||
# Runs post-merge + weekly (not on PRs — dependency-review already gates PRs, and SARIF
|
||||
# upload needs a write token that fork PRs don't get).
|
||||
on:
|
||||
push:
|
||||
branches: [develop, master]
|
||||
schedule:
|
||||
- cron: "50 7 * * 5" # Weekly, Friday 07:50 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: trivy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trivy:
|
||||
name: 🔎 Filesystem scan
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # upload SARIF to code scanning
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
# Rotate the DB cache weekly (matches the scheduled scan): cache hits within the week
|
||||
# instead of a fresh immutable entry per run, still refreshing the DB every week.
|
||||
- name: 🗓️ Compute weekly Trivy cache key
|
||||
id: trivy-cache-key
|
||||
run: echo "value=trivy-db-${{ runner.os }}-$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 💾 Cache Trivy vulnerability DB
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/trivy
|
||||
key: ${{ steps.trivy-cache-key.outputs.value }}
|
||||
restore-keys: trivy-db-${{ runner.os }}-
|
||||
|
||||
- name: 🔎 Run Trivy filesystem scan
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
scanners: vuln,secret,misconfig
|
||||
ignore-unfixed: true
|
||||
severity: CRITICAL,HIGH
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
|
||||
- name: 📤 Upload results to code scanning
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-fs
|
||||
2
.github/workflows/update-issue-form.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: "🟢 Setup Node.js"
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
14
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Dependencies and Package Managers
|
||||
node_modules/
|
||||
bun.lock
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
|
||||
@@ -20,8 +21,10 @@ web-build/
|
||||
# Gradle caches (top-level + per-module native projects)
|
||||
**/.gradle/
|
||||
|
||||
# Native module build outputs (any module)
|
||||
modules/*/android/build/
|
||||
# Module-specific Builds
|
||||
modules/mpv-player/android/build
|
||||
modules/player/android
|
||||
modules/hls-downloader/android/build
|
||||
|
||||
# Generated Applications
|
||||
Streamyfin.app
|
||||
@@ -66,12 +69,13 @@ certs/
|
||||
|
||||
# Version and Backup Files
|
||||
/version-backup-*
|
||||
/modules/sf-player/android/build
|
||||
/modules/music-controls/android/build
|
||||
modules/background-downloader/android/build/*
|
||||
/modules/mpv-player/android/build
|
||||
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
|
||||
!.eas/build/
|
||||
!.eas/build/**
|
||||
.claude/
|
||||
.agents/skills/**
|
||||
skills-lock.json
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
// Build metadata, injected into `extra.build` and read at runtime via
|
||||
// expo-constants (see utils/version.ts). Sources in priority order:
|
||||
// EAS cloud build → GitHub Actions → explicit EXPO_PUBLIC_* → local git → null.
|
||||
const git = (args) => {
|
||||
try {
|
||||
return execFileSync("git", args, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildMeta = {
|
||||
commit:
|
||||
(
|
||||
process.env.EAS_BUILD_GIT_COMMIT_HASH ||
|
||||
process.env.GITHUB_SHA ||
|
||||
process.env.EXPO_PUBLIC_GIT_COMMIT ||
|
||||
git(["rev-parse", "HEAD"]) ||
|
||||
""
|
||||
).slice(0, 7) || null,
|
||||
branch:
|
||||
process.env.EAS_BUILD_GIT_BRANCH ||
|
||||
process.env.GITHUB_HEAD_REF ||
|
||||
process.env.GITHUB_REF_NAME ||
|
||||
process.env.EXPO_PUBLIC_GIT_BRANCH ||
|
||||
git(["rev-parse", "--abbrev-ref", "HEAD"]) ||
|
||||
null,
|
||||
profile:
|
||||
process.env.EAS_BUILD_PROFILE ||
|
||||
process.env.EXPO_PUBLIC_BUILD_PROFILE ||
|
||||
null,
|
||||
builtAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
module.exports = ({ config }) => {
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
config.plugins.push("expo-background-task");
|
||||
@@ -60,8 +22,6 @@ module.exports = ({ config }) => {
|
||||
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
|
||||
}
|
||||
|
||||
config.extra = { ...config.extra, build: buildMeta };
|
||||
|
||||
return {
|
||||
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
|
||||
...config,
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
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();
|
||||
@@ -30,8 +20,6 @@ export default function FavoritesPage() {
|
||||
return <TVFavorites />;
|
||||
}
|
||||
|
||||
const isWatchlist = watchlistEnabled && viewType === "Watchlist";
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -46,26 +34,7 @@ export default function FavoritesPage() {
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}>
|
||||
{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 />
|
||||
)}
|
||||
<Favorites />
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
@@ -11,7 +10,7 @@ import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
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 { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
@@ -53,13 +52,9 @@ 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;
|
||||
@@ -82,7 +77,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
userId: user.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: [filter],
|
||||
filters: ["IsFavorite"],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -95,12 +90,12 @@ export default function FavoritesSeeAllScreen() {
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
[api, itemType, user?.Id, filter],
|
||||
[api, itemType, user?.Id],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["favorites", "see-all", itemType, filter],
|
||||
queryKey: ["favorites", "see-all", itemType],
|
||||
queryFn: ({ pageParam = 0 }) => fetchItems({ pageParam }),
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (!lastPage || lastPage.length < pageSize) return undefined;
|
||||
@@ -160,13 +155,15 @@ export default function FavoritesSeeAllScreen() {
|
||||
options={{
|
||||
headerTitle: headerTitle,
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerTransparent: true,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
{!itemType ? (
|
||||
<View className='flex-1 items-center justify-center px-6'>
|
||||
<Text className='text-neutral-500'>{t("favorites.noData")}</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("favorites.noData", { defaultValue: "No items found." })}
|
||||
</Text>
|
||||
</View>
|
||||
) : isLoading ? (
|
||||
<View className='justify-center items-center h-full'>
|
||||
@@ -197,7 +194,7 @@ export default function FavoritesSeeAllScreen() {
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full py-12'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("home.no_items")}
|
||||
{t("home.no_items", { defaultValue: "No items" })}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -137,12 +137,12 @@ export default function DownloadsPage() {
|
||||
deleteFileByType("Episode")
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_series_successfully"),
|
||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_series"));
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
});
|
||||
const deleteOtherMedia = () =>
|
||||
Promise.all(
|
||||
@@ -207,7 +207,7 @@ export default function DownloadsPage() {
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.series")}
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
@@ -288,7 +288,7 @@ export default function DownloadsPage() {
|
||||
{t("home.downloads.delete_all_movies_button")}
|
||||
</Button>
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_series_button")}
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
</Button>
|
||||
{otherMedia.length > 0 && (
|
||||
<Button color='purple' onPress={deleteOtherMedia}>
|
||||
|
||||
@@ -59,19 +59,17 @@ function SettingsMobile() {
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
{Platform.OS !== "ios" && (
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
)}
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
|
||||
@@ -179,15 +179,18 @@ export default function SettingsTV() {
|
||||
// Handle clearing all cache in the entire app
|
||||
const handleClearCache = async () => {
|
||||
Alert.alert(
|
||||
t("home.settings.storage.clear_all_cache_confirm"),
|
||||
t("home.settings.storage.clear_all_cache_confirm_desc"),
|
||||
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
|
||||
t(
|
||||
"home.settings.storage.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.",
|
||||
),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
text: t("common.cancel", "Cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.ok"),
|
||||
text: t("common.ok", "OK"),
|
||||
onPress: async () => {
|
||||
try {
|
||||
// 1. Clear React Query Cache (memory & MMKV)
|
||||
@@ -240,8 +243,11 @@ export default function SettingsTV() {
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
Alert.alert(
|
||||
t("home.settings.toasts.error_deleting_files"),
|
||||
t("home.settings.storage.clear_all_cache_error_desc"),
|
||||
t("home.settings.toasts.error_deleting_files", "Error"),
|
||||
t(
|
||||
"home.settings.storage.clear_all_cache_error_desc",
|
||||
"An error occurred while clearing the cache.",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
||||
};
|
||||
|
||||
const handleRefreshFromServer = useCallback(async () => {
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||
// Update local state with new values
|
||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||
setUrl(newUrl);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
@@ -77,7 +76,7 @@ const MobilePage: React.FC = () => {
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
|
||||
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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";
|
||||
@@ -19,7 +18,6 @@ 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,
|
||||
@@ -32,7 +30,6 @@ import { storage } from "@/utils/mmkv";
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const params = useLocalSearchParams();
|
||||
const {
|
||||
id: seriesId,
|
||||
@@ -140,7 +137,6 @@ 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'
|
||||
@@ -161,7 +157,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
) : null,
|
||||
});
|
||||
}, [allEpisodes, isLoading, item, isOffline, settings?.useKefinTweaks]);
|
||||
}, [allEpisodes, isLoading, item, isOffline]);
|
||||
|
||||
// For offline mode, we can show the page even without backdropUrl
|
||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||
|
||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
trigger={
|
||||
<View>
|
||||
<View className='pl-1.5'>
|
||||
<Ionicons
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
|
||||
@@ -40,8 +40,6 @@ const Layout = () => {
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarActiveTintColor: "#FFFFFF",
|
||||
tabBarInactiveTintColor: "#9CA3AF",
|
||||
tabBarLabelStyle: {
|
||||
fontSize: TAB_LABEL_FONT_SIZE,
|
||||
fontWeight: "600",
|
||||
|
||||
@@ -102,8 +102,8 @@ export default function TabLayout() {
|
||||
!settings?.streamyStatsServerUrl || settings?.hideWatchlistsTab,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/list.star.png")
|
||||
: (_e) => ({ sfSymbol: "list.star" }),
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.bullet.rectangle" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -112,7 +112,7 @@ export default function TabLayout() {
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/rectangle.stack.fill.png")
|
||||
? (_e) => require("@/assets/icons/server.rack.png")
|
||||
: (_e) => ({ sfSymbol: "rectangle.stack.fill" }),
|
||||
}}
|
||||
/>
|
||||
@@ -123,8 +123,8 @@ export default function TabLayout() {
|
||||
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/link.png")
|
||||
: (_e) => ({ sfSymbol: "link" }),
|
||||
? (_e) => require("@/assets/icons/list.png")
|
||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
@@ -134,7 +134,7 @@ export default function TabLayout() {
|
||||
tabBarItemHidden: !Platform.isTV,
|
||||
tabBarIcon:
|
||||
Platform.OS === "android"
|
||||
? (_e) => require("@/assets/icons/gearshape.fill.png")
|
||||
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
|
||||
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -274,11 +274,6 @@ export default function DirectPlayerPage() {
|
||||
};
|
||||
|
||||
if (itemId) {
|
||||
setItem(null);
|
||||
setDownloadedItem(null);
|
||||
// Clear the previous episode's stream so the loader gate stays closed
|
||||
// until the new item's stream resolves (avoids a stale MPV source frame).
|
||||
setStream(null);
|
||||
fetchItemData();
|
||||
}
|
||||
}, [itemId, offline, api, user?.Id]);
|
||||
@@ -321,12 +316,6 @@ export default function DirectPlayerPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure item matches the current itemId to avoid race conditions
|
||||
if (item.Id !== itemId) {
|
||||
setStreamStatus({ isLoading: false, isError: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
@@ -399,7 +388,6 @@ export default function DirectPlayerPage() {
|
||||
item,
|
||||
user?.Id,
|
||||
downloadedItem,
|
||||
offline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -439,15 +427,21 @@ export default function DirectPlayerPage() {
|
||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.get());
|
||||
await getPlaystateApi(api).reportPlaybackStopped({
|
||||
playbackStopInfo: {
|
||||
ItemId: item.Id,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: currentTimeInTicks,
|
||||
PlaySessionId: stream.sessionId,
|
||||
},
|
||||
await getPlaystateApi(api).onPlaybackStopped({
|
||||
itemId: item.Id,
|
||||
mediaSourceId: mediaSourceId,
|
||||
positionTicks: currentTimeInTicks,
|
||||
playSessionId: stream.sessionId,
|
||||
});
|
||||
}, [api, item, mediaSourceId, stream, progress, offline]);
|
||||
}, [
|
||||
api,
|
||||
item,
|
||||
mediaSourceId,
|
||||
stream,
|
||||
progress,
|
||||
offline,
|
||||
revalidateProgressCache,
|
||||
]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// Update URL with final playback position before stopping
|
||||
@@ -465,10 +459,9 @@ export default function DirectPlayerPage() {
|
||||
useEffect(() => {
|
||||
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
|
||||
return () => {
|
||||
reportPlaybackStopped();
|
||||
beforeRemoveListener();
|
||||
};
|
||||
}, [navigation, stop, reportPlaybackStopped]);
|
||||
}, [navigation, stop]);
|
||||
|
||||
const currentPlayStateInfo = useCallback(():
|
||||
| PlaybackProgressInfo
|
||||
|
||||
BIN
assets/icons/gear.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/heart.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 112 KiB |
118
assets/icons/jellyseerr-logo.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 13 KiB |
BIN
assets/icons/list.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><path fill="url(#paint0_linear)" fill-rule="evenodd" d="M48 96C74.5097 96 96 74.5097 96 48C96 21.4903 74.5097 0 48 0C21.4903 0 0 21.4903 0 48C0 74.5097 21.4903 96 48 96ZM80.0001 52C80.0001 67.464 67.4641 80 52.0001 80C36.5361 80 24.0001 67.464 24.0001 52C24.0001 49.1303 24.4318 46.3615 25.2338 43.7548C27.4288 48.6165 32.3194 52 38.0001 52C45.7321 52 52.0001 45.732 52.0001 38C52.0001 32.3192 48.6166 27.4287 43.755 25.2337C46.3616 24.4317 49.1304 24 52.0001 24C67.4641 24 80.0001 36.536 80.0001 52Z" clip-rule="evenodd"/><path fill="#131928" fill-rule="evenodd" d="M80.0002 52C80.0002 67.464 67.4642 80 52.0002 80C36.864 80 24.5329 67.9897 24.017 52.9791C24.0057 53.318 24 53.6583 24 54C24 70.5685 37.4315 84 54 84C70.5685 84 84 70.5685 84 54C84 37.4315 70.5685 24 54 24C53.6597 24 53.3207 24.0057 52.9831 24.0169C67.9919 24.5347 80.0002 36.865 80.0002 52Z" clip-rule="evenodd" opacity=".2"/><path fill="url(#paint1_linear)" fill-rule="evenodd" d="M48 12C28.1177 12 12 28.1177 12 48C12 50.2091 10.2091 52 8 52C5.79086 52 4 50.2091 4 48C4 23.6995 23.6995 4 48 4C50.2091 4 52 5.79086 52 8C52 10.2091 50.2091 12 48 12Z" clip-rule="evenodd"/><defs><linearGradient id="paint0_linear" x1="48" x2="117.5" y1="0" y2="69.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C395FC"/><stop offset="1" stop-color="#4F65F5"/></linearGradient><linearGradient id="paint1_linear" x1="28" x2="28" y1="8" y2="48" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".4"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
BIN
assets/icons/server.rack.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
65
assets/images/not-rotten-tomatoes.svg
Normal file
@@ -0,0 +1,65 @@
|
||||
<svg
|
||||
type="certified"
|
||||
viewBox="0 0 80 80"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g transform="translate(2.29, 0)">
|
||||
<path
|
||||
d="M42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.6297143,21.1451429 51.5085714,21.4605714 51.3097143,21.408 C47.8902857,20.4868571 42.5577143,25.0217143 39.1017143,22.0891429 C39.008,22.9485714 38.2331429,27.0857143 32.3314286,26.4731429 C32.192,26.4594286 32.1371429,26.304 32.24,26.2171429 C33.1542857,25.44 34.2765714,23.2891429 33.3142857,21.9154286 C30.3108571,23.9085714 28.7565714,23.9954286 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.9222857 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.312 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8354286 C28.224,15.3188571 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.7862857 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6925714 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857"
|
||||
id="Fill-2"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<mask id="mask-2" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.921142857 75.0534777 0.921142857 75.0534777 79.8628571 0.137142857 79.8628571"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0491429,59.1817143 C9.90628571,55.3554286 7.86971429,50.576 7.51771429,44.9622857 C6.912,35.2342857 10.2354286,26.0845714 23.1794286,21.4834286 C23.1908571,21.5245714 23.1725714,21.5748571 23.2182857,21.5954286 C23.0377143,21.5177143 23.1451429,21.2228571 23.3577143,21.1748571 C24.5074286,20.9165714 27.2434286,19.92 29.696,19.4582857 C30.1645714,19.3691429 30.624,19.3165714 31.0674286,19.3097143 C28.528,18.7062857 27.4217143,18.1805714 25.7485714,18.1874286 C25.5657143,18.1897143 25.4742857,17.9611429 25.6068571,17.8331429 C28.224,15.3165714 32.9691429,15.1885714 35.2548571,17.0628571 L33.2068571,12.784 L35.696,12.4114286 C35.696,12.4114286 36.3451429,14.6902857 36.9257143,16.7428571 C39.5177143,13.904 43.5268571,14.192 44.8777143,16.672 C44.9577143,16.8182857 44.8251429,16.992 44.6605714,16.9622857 C43.3005714,16.7314286 42.3702857,17.8628571 42.1737143,18.7977143 L42.1942857,18.8022857 C44.3794286,18.608 49.1565714,18.7177143 51.4902857,21.0057143 C51.328,20.8502857 51.1337143,20.7245714 50.9508571,20.5874286 C60.2765714,23.504 66.7474286,30.1531429 67.44,41.2251429 C67.8811429,48.2948571 65.5702857,54.3885714 61.568,59.1154286 C62.784,59.2891429 63.9931429,59.4925714 65.2045714,59.6937143 C70.304,53.4537143 73.2502857,45.5428571 73.2502857,37.056 C73.2502857,17.7165714 57.5337143,2.56685714 37.472,2.56685714 C17.4102857,2.56685714 1.69371429,17.7165714 1.69371429,37.056 C1.69371429,45.5565714 4.64,53.472 9.744,59.7097143 C10.8434286,59.5268571 11.9451429,59.3462857 13.0491429,59.1817143"
|
||||
fill="#FFD700"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M9.744,59.7097143 C4.64,53.472 1.69371429,45.5565714 1.69371429,37.056 C1.69371429,17.7165714 17.4102857,2.56685714 37.472,2.56685714 C57.5337143,2.56685714 73.2502857,17.7165714 73.2502857,37.056 C73.2502857,45.5428571 70.304,53.4537143 65.2045714,59.6937143 C65.8125714,59.7942857 66.4205714,59.8742857 67.0285714,59.984 C71.9497143,53.6457143 74.8937143,45.6982857 74.8937143,37.056 C74.8937143,16.3862857 58.1394286,0.921142857 37.472,0.921142857 C16.8022857,0.921142857 0.048,16.3862857 0.048,37.056 C0.048,45.7074286 2.99885714,53.6594286 7.92914286,59.9977143 C8.53257143,59.8902857 9.13828571,59.8102857 9.744,59.7097143"
|
||||
fill="#FA6E0F"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M58.2857143,74.9394286 C62.3748571,75.1954286 65.7874286,77.2137143 67.8468571,79.9474286 C67.9131429,80.0182857 68.0114286,80.016 68.0411429,79.9382857 C68.7451429,77.0971429 68.9394286,74.0662857 68.5851429,71.0125714 C68.5874286,70.9805714 68.6125714,70.9577143 68.6537143,70.9485714 C70.576,70.3428571 72.7017143,70.0137143 74.9645714,70.0457143 C75.0857143,70.0594286 75.0834286,69.9405714 74.9554286,69.8194286 C72.5577143,67.4994286 69.6297143,65.6914286 66.416,64.5417143 C65.3051429,67.68 64.2217143,70.816 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286 L58.2857143,74.9394286"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 L58.2857143,74.9394286 C58.2857143,74.9394286 58.3451429,74.512 58.528,73.3325714 C60.9417143,73.6754286 62.9645714,74.0434286 62.9645714,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
<g transform="translate(0, 20.57)">
|
||||
<mask id="mask-4" fill="white">
|
||||
<polygon
|
||||
points="0.137142857 0.016 67.4935952 0.016 67.4935952 59.2914286 0.137142857 59.2914286"
|
||||
></polygon>
|
||||
</mask>
|
||||
<path
|
||||
d="M13.0765714,38.6057143 C29.1177143,36.2605714 45.5222857,36.2354286 61.568,38.544 C65.5702857,33.8171429 67.8811429,27.7234286 67.44,20.6537143 C66.7474286,9.58171429 60.2765714,2.93257143 50.9508571,0.016 C51.1337143,0.153142857 51.328,0.278857143 51.4902857,0.434285714 C51.6297143,0.573714286 51.5085714,0.889142857 51.3097143,0.836571429 C47.8902857,-0.0845714286 42.5577143,4.45028571 39.1017143,1.51771429 C39.008,2.37485714 38.2331429,6.51428571 32.3314286,5.90171429 C32.192,5.888 32.1371429,5.73257143 32.24,5.64571429 C33.1542857,4.86857143 34.2765714,2.71542857 33.3142857,1.344 C30.3108571,3.33714286 28.7565714,3.424 23.2182857,1.024 C23.1725714,1.00342857 23.1908571,0.953142857 23.1794286,0.912 C10.2354286,5.51314286 6.912,14.6628571 7.51771429,24.3908571 C7.86971429,30.0091429 9.93142857,34.7748571 13.0765714,38.6057143"
|
||||
fill="#FA3200"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,53.472 C12,53.488 11.9154286,53.4514286 11.8948571,53.392 C10.8274286,50.2445714 9.73485714,47.0971429 8.62171429,43.9611429 C5.41028571,45.1108571 2.49371429,46.9302857 0.0982857143,49.248 C-0.0297142857,49.3691429 -0.032,49.488 0.0891428571,49.4742857 C2.352,49.4422857 4.47771429,49.7714286 6.4,50.3771429 C6.44114286,50.3862857 6.46628571,50.4091429 6.46857143,50.4411429 C6.11428571,53.4948571 6.30857143,56.5257143 7.01257143,59.3668571 C7.04228571,59.4445714 7.14057143,59.4468571 7.20685714,59.376 C9.26628571,56.6422857 12.6742857,54.624 16.7657143,54.368 L12.0868571,53.472"
|
||||
fill="#0AC855"
|
||||
mask="url(#mask-4)"
|
||||
></path>
|
||||
</g>
|
||||
<path
|
||||
d="M62.9645714,74.0434286 C46.192,71.104 28.8571429,71.104 12.0868571,74.0434286 C12,74.0594286 11.9154286,74.0228571 11.8948571,73.9634286 C10.3428571,69.3851429 8.74285714,64.8182857 7.09257143,60.2628571 C7.06971429,60.1988571 7.14057143,60.1257143 7.248,60.1074286 C27.1885714,56.464 47.8605714,56.464 67.8034286,60.1074286 C67.9108571,60.1257143 67.9817143,60.1988571 67.9565714,60.2628571 C66.3085714,64.8182857 64.7085714,69.3851429 63.1565714,73.9634286 C63.136,74.0228571 63.0514286,74.0594286 62.9645714,74.0434286"
|
||||
fill="#00912D"
|
||||
></path>
|
||||
<path
|
||||
d="M12.0868571,74.0434286 L16.7657143,74.9394286 C16.7657143,74.9394286 16.704,74.512 16.5211429,73.3325714 C14.1074286,73.6754286 12.0868571,74.0434286 12.0868571,74.0434286"
|
||||
fill="#0B4902"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/images/rotten-tomatoes.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M370.57 474.214l23.466-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278c-6.748 10.248-19.863 20.86-33.59 27.936zm-78.197 21.631l2.947-244.528c20.894-.599 47.933-3.43 70.97-8.346l-19.07 241.17c-22.724 7.518-35.934 9.848-54.847 11.704zm-99.694-252.874c23.038 4.916 50.077 7.747 70.971 8.346l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705l-19.072-241.17zm-67.974-26.975c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.59-27.936l-30.104-230.282z"/><path fill="gold" d="M118.905 157.445c1.357 28.827 72.771 51.677 160.578 51.176 76.687-.438 140.659-18.546 156.329-42.336a22.976 22.976 0 00-14.058-7.426c.06-.7.098-1.406.095-2.122-.065-11.4-8.429-20.788-19.327-22.54.287-1.474.438-2.999.43-4.559-.072-12.696-10.426-22.928-23.124-22.856-.287.001-.568.036-.853.049a22.911 22.911 0 001.254-7.56c-.074-12.697-10.425-22.93-23.123-22.858a22.914 22.914 0 00-8.247 1.6c-3.632-6.835-10.606-11.6-18.737-12.149-1.416-11.4-11.157-20.195-22.93-20.129-7.41.042-13.963 3.6-18.136 9.065-4.233-4.605-10.3-7.494-17.047-7.456-12.698.072-22.932 10.424-22.86 23.118a22.983 22.983 0 001.115 6.946 22.918 22.918 0 00-13.07 7.459c-2.644-9.847-11.637-17.084-22.314-17.024-9.975.057-18.406 6.47-21.537 15.366-8.474 3.426-14.439 11.738-14.383 21.433.012 2.154.342 4.227.907 6.202a22.876 22.876 0 00-9.328-1.932c-10.012.058-18.47 6.516-21.574 15.465a22.83 22.83 0 00-9.788-2.149c-12.698.072-22.934 10.422-22.86 23.118a22.833 22.833 0 003.159 11.463c-.202.203-.379.426-.571.636"/><path fill="#FA320A" d="M404.161 446.278c-6.749 10.248-19.864 20.86-33.59 27.936l23.465-237.956c14.93-4.796 29.498-11.15 40.23-20.262L404.16 446.278zM347.22 484.14c-22.723 7.519-35.934 9.85-54.847 11.705l2.947-244.528c20.894-.599 47.933-3.43 70.973-8.346L347.22 484.14zm-135.47 0l-19.07-241.17c23.037 4.917 50.076 7.748 70.97 8.347l2.948 244.528c-18.914-1.856-32.123-4.186-54.847-11.705zm-56.94-37.862l-30.105-230.282c10.732 9.112 25.3 15.466 40.23 20.262l23.464 237.956c-13.726-7.075-26.84-17.688-33.588-27.936zm247.668-321.143c.298 1.453.465 2.955.473 4.498a23.018 23.018 0 01-.43 4.56c10.9 1.749 19.263 11.137 19.328 22.54a23.59 23.59 0 01-.095 2.12 22.976 22.976 0 0114.058 7.425c-15.669 23.792-79.642 41.9-156.327 42.34-87.807.502-159.221-22.346-160.58-51.175.192-.208.37-.433.57-.634-1.355-2.311-2.29-4.887-2.773-7.62-8.408 7.979-13.495 14.412-12.6 23.78.085 1.251 37.196 266.911 37.196 266.911 4.282 42.075 65.391 75.703 138.187 76.12 72.796-.417 133.907-34.045 138.187-76.12 0 0 37.11-265.66 37.197-266.912 1.777-18.736-20.15-35.745-52.39-47.833z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><g transform="translate(33 140)"><path d="m43.802 267.32l237.94 23.482c4.7937 14.937 11.149 29.517 20.259 40.256l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.612zm222.88-75.298c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083c-7.518-22.739-9.8466-35.959-11.704-54.885l244.51 2.951zm8.3462-102.71c-4.9146 23.053-7.7456 50.111-8.3462 71.017l-244.51 2.951c1.8576-18.926 4.1862-32.146 11.704-54.885l241.15-19.083zm26.973-68.019c-9.1095 10.74-15.465 25.318-20.259 40.257l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125z" fill="#fff"/><path d="m303.57 264.67c3.155-7.8209 14.337-12.586 22.367-12.028 8.5825 0.59581 17.699 9.6258 19.292 18.507 0.29589-0.32244 0.60578-0.62735 0.92093-0.92701 2.7558-2.6356 6.2084-4.3845 9.9867-4.8664-0.57777-2.562-0.71609-5.3045-0.3204-8.1188 1.3954-9.901 9.3336-17.326 18.422-17.252 5.8652 0.047314 11.011 3.0509 14.364 7.6649 0.29939-0.37501 0.6303-0.71848 0.95245-1.069 3.8343-19.991 6.2644-42.578 6.8177-66.547 1.8996-82.42-18.993-149.75-46.663-150.39-27.672-0.63962-51.644 65.656-53.544 148.08 0 0-1.4654 30.062 7.4042 86.951" fill="#00641E"/><path d="m490.91 354.8c1.6353-2.732 2.5492-6.0072 2.4862-9.4874 0.5305-11.245-7.1819-21.439-17.913-20.31 0.3099-1.2862 0.51299-2.6233 0.59178-4.0024 0.64255-11.264-7.1188-20.972-17.337-21.682-0.2241-0.014019-0.44471-0.021029-0.66706-0.028039 1.0627-2.795 1.5897-5.916 1.4024-9.2228-0.52875-9.3717-6.9858-17.268-15.386-18.822-3.0342-0.56076-5.9773-0.28213-8.6718 0.65188-2.5457-6.277-7.8909-10.933-14.393-11.916-0.51474-10.192-7.8699-18.58-17.34-19.238-5.9545-0.41356-11.426 2.3237-15.085 6.9061-3.3528-4.614-8.4985-7.6158-14.364-7.6632-9.0885-0.075352-17.027 7.3495-18.422 17.252-0.39569 2.8126-0.25737 5.555 0.3204 8.1188-3.7783 0.48015-7.2309 2.2308-9.9867 4.8646-0.31515 0.29966-0.62504 0.60457-0.92093 0.92701-1.5932-8.8811-10.71-17.909-19.292-18.507-8.0293-0.55726-19.357 4.3249-22.367 12.028 1.3201 13.434 9.71 50.053 40.055 82.903l0.26963 0.019276c2.9256 2.6496 6.7459 4.1093 10.818 3.7466 2.5247-0.22606 4.8498-1.1303 6.8527-2.5252l0.48848 0.033295c2.67 1.8558 5.8793 2.8266 9.2706 2.5252 1.2956-0.11566 2.5317-0.42758 3.7065-0.87269 2.9064 6.0142 9.3879 9.901 16.622 9.2631 5.6026-0.49417 10.365-3.5959 13.164-7.9611l0.90692 0.063086c2.7961 2.774 6.513 4.3897 10.522 4.2688 3.3143 5.0188 9.4019 8.1065 16.12 7.516 2.5299-0.22255 4.8918-0.95505 6.998-2.0643 3.5139 4.3266 9.2811 6.8991 15.609 6.3419 6.2557-0.5485 11.54-4.02 14.414-8.8197 2.8241 2.2693 6.3625 3.4872 10.12 3.1525 3.6452-0.32594 6.8842-2.0485 9.3179-4.6543l0.40619 0.028038c0.55326-0.80259 1.026-1.6245 1.4654-2.4533 0.010505-0.015772 0.019259-0.033296 0.028014-0.049067 0.059527-0.1104 0.13306-0.21905 0.18909-0.3312" fill="#FFD700"/><path d="m281.75 61.547l-237.94 23.48c7.0751-13.735 17.689-26.859 27.936-33.612l230.27-30.125c-9.1095 10.74-15.465 25.318-20.259 40.257zm20.259 269.51l-230.27-30.125c-10.248-6.7519-20.861-19.877-27.936-33.611l237.94 23.48c4.7937 14.937 11.149 29.517 20.259 40.256zm-268.13-87.102c-7.518-22.739-9.8466-35.957-11.704-54.885l244.51 2.951c0.60053 20.906 3.4316 47.964 8.3462 71.017l-241.15-19.083zm0-135.56l241.15-19.083c-4.9146 23.053-7.7456 50.111-8.3462 71.019l-244.51 2.9493c1.8576-18.926 4.1862-32.146 11.704-54.885zm344.72-82.679c-15.255-17.778-26.206-26.124-35.587-25.04-1.7491 0.22255-266.89 37.222-266.89 37.222-42.074 4.2828-75.7 65.432-76.117 138.28 0.4167 72.843 34.043 133.99 76.117 138.28 0 0 265.64 37.135 266.89 37.221 2.2183-0.014019 4.4086-0.31192 6.5673-0.86568-2.101-0.6256-4.0391-1.7208-5.6867-3.2139l-0.26963-0.019276c-30.345-32.848-38.735-69.47-40.055-82.903 0.003501-0.010514 0.010505-0.019276 0.014006-0.02979-0.003501 0.010514-0.010505 0.019276-0.014006 0.02979-8.8697-56.889-7.4042-86.951-7.4042-86.951 1.8996-82.42 25.872-148.72 53.544-148.08 27.67 0.63962 48.562 67.973 46.663 150.39-0.55326 23.969-2.9834 46.556-6.8177 66.547 3.9393-4.3512 9.2233-6.1842 14.133-5.8372 0.90342 0.064838 1.7823 0.2173 2.6437 0.41531 16.804-92.03-0.81238-181.89-27.729-215.44z" fill="#04A53C"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="m478.29 296.98c-3.99-63.966-36.52-111.82-85.468-138.58 0.278 1.56-1.109 3.508-2.688 2.818-32.016-14.006-86.328 31.32-124.28 7.584 0.285 8.519-1.378 50.072-59.914 52.483-1.382 0.056-2.142-1.355-1.268-2.354 7.828-8.929 15.732-31.535 4.367-43.586-24.338 21.81-38.472 30.017-85.138 19.186-29.878 31.241-46.809 74-43.485 127.26 6.78 108.74 108.63 170.89 211.19 164.49 102.56-6.395 193.47-80.572 186.68-189.31" fill="#FA320A"/><path d="M291.375 132.293c21.075-5.023 81.693-.49 101.114 25.274 1.166 1.545-.475 4.468-2.355 3.648-32.016-14.006-86.328 31.32-124.282 7.584.285 8.519-1.378 50.072-59.914 52.483-1.382.056-2.142-1.355-1.268-2.354 7.828-8.929 15.73-31.535 4.367-43.586-26.512 23.758-40.884 31.392-98.426 15.838-1.883-.508-1.241-3.535.762-4.298 10.876-4.157 35.515-22.361 58.824-30.385 4.438-1.526 8.862-2.71 13.18-3.4-25.665-2.293-37.235-5.862-53.559-3.4-1.789.27-3.004-1.813-1.895-3.241 21.995-28.332 62.513-36.888 87.512-21.837-15.41-19.094-27.48-34.321-27.48-34.321l28.601-16.246s11.817 26.4 20.414 45.614c21.275-31.435 60.86-34.336 77.585-12.033.992 1.326-.045 3.21-1.702 3.171-13.612-.331-21.107 12.05-21.675 21.466l.197.023" fill="#00912D"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 560 560" xmlns="http://www.w3.org/2000/svg"><path d="M445.185 444.684c-79.369 4.167-95.587-86.652-126.726-86.006-13.268.279-23.726 14.151-19.133 30.32 2.525 8.888 9.53 21.923 13.944 30.011 15.57 28.544-7.447 60.845-34.383 63.577-44.76 4.54-63.433-21.426-62.278-48.007 1.3-29.84 26.6-60.331.65-73.305-27.194-13.597-49.301 39.572-75.325 51.439-23.553 10.741-56.248 2.413-67.872-23.741-8.164-18.379-6.68-53.768 29.67-67.27 22.706-8.433 73.305 11.029 75.9-13.623 2.992-28.416-53.155-30.812-70.06-37.626-29.912-12.055-47.567-37.85-33.734-65.522 10.378-20.757 40.915-29.203 64.223-20.11 27.922 10.892 32.404 39.853 46.71 51.897 12.324 10.38 29.19 11.68 40.22 4.543 8.135-5.265 10.843-16.828 7.774-27.39-4.07-14.023-14.875-22.773-25.415-31.346-18.758-15.249-45.24-28.36-29.222-69.983 13.13-34.11 51.642-35.34 51.642-35.34 15.3-1.72 29.002 2.9 40.167 12.875 14.927 13.335 17.834 31.16 15.336 50.176-2.283 17.358-8.426 32.56-11.63 49.759-3.717 19.966 6.954 40.086 27.249 40.869 26.694 1.031 34.698-19.486 37.964-32.492 4.782-19.028 11.058-36.694 28.718-47.82 25.346-15.97 60.552-12.47 76.886 18.222 12.92 24.284 8.772 57.715-11.047 75.97-8.892 8.188-19.584 11.075-31.148 11.156-16.585.117-33.162-.29-48.556 7.471-10.48 5.281-15.047 13.888-15.045 25.423 0 11.242 5.853 18.585 15.336 23.363 17.86 9.003 37.577 10.843 56.871 14.222 27.98 4.9 52.581 14.755 68.375 40.72.142.228.28.458.415.69 18.139 30.741-.831 75.005-36.476 76.878" fill="#0AC855"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 190.24 81.52"><defs><linearGradient id="a" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><path d="M105.67 36.06h66.9a17.67 17.67 0 0017.67-17.66A17.67 17.67 0 00172.57.73h-66.9A17.67 17.67 0 0088 18.4a17.67 17.67 0 0017.67 17.66zm-88 45h76.9a17.67 17.67 0 0017.67-17.66 17.67 17.67 0 00-17.67-17.67h-76.9A17.67 17.67 0 000 63.4a17.67 17.67 0 0017.67 17.66zm-7.26-45.64h7.8V6.92h10.1V0h-28v6.9h10.1zm28.1 0h7.8V8.25h.1l9 27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2 23.1h-.1L50.31 0h-11.8zm113.92 20.25a15.07 15.07 0 00-4.52-5.52 18.57 18.57 0 00-6.68-3.08 33.54 33.54 0 00-8.07-1h-11.7v35.4h12.75a24.58 24.58 0 007.55-1.15 19.34 19.34 0 006.35-3.32 16.27 16.27 0 004.37-5.5 16.91 16.91 0 001.63-7.58 18.5 18.5 0 00-1.68-8.25zM145 68.6a8.8 8.8 0 01-2.64 3.4 10.7 10.7 0 01-4 1.82 21.57 21.57 0 01-5 .55h-4.05v-21h4.6a17 17 0 014.67.63 11.66 11.66 0 013.88 1.87A9.14 9.14 0 01145 59a9.87 9.87 0 011 4.52 11.89 11.89 0 01-1 5.08zm44.63-.13a8 8 0 00-1.58-2.62 8.38 8.38 0 00-2.42-1.85 10.31 10.31 0 00-3.17-1v-.1a9.22 9.22 0 004.42-2.82 7.43 7.43 0 001.68-5 8.42 8.42 0 00-1.15-4.65 8.09 8.09 0 00-3-2.72 12.56 12.56 0 00-4.18-1.3 32.84 32.84 0 00-4.62-.33h-13.2v35.4h14.5a22.41 22.41 0 004.72-.5 13.53 13.53 0 004.28-1.65 9.42 9.42 0 003.1-3 8.52 8.52 0 001.2-4.68 9.39 9.39 0 00-.55-3.18zm-19.42-15.75h5.3a10 10 0 011.85.18 6.18 6.18 0 011.7.57 3.39 3.39 0 011.22 1.13 3.22 3.22 0 01.48 1.82 3.63 3.63 0 01-.43 1.8 3.4 3.4 0 01-1.12 1.2 4.92 4.92 0 01-1.58.65 7.51 7.51 0 01-1.77.2h-5.65zm11.72 20a3.9 3.9 0 01-1.22 1.3 4.64 4.64 0 01-1.68.7 8.18 8.18 0 01-1.82.2h-7v-8h5.9a15.35 15.35 0 012 .15 8.47 8.47 0 012.05.55 4 4 0 011.57 1.18 3.11 3.11 0 01.63 2 3.71 3.71 0 01-.43 1.92z" fill="url(#a)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
6
bun.lock
@@ -108,7 +108,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"expo-doctor": "1.19.7",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"react-test-renderer": "19.2.3",
|
||||
@@ -969,7 +969,7 @@
|
||||
|
||||
"expo-device": ["expo-device@56.0.4", "", { "dependencies": { "ua-parser-js": "^0.7.33" }, "peerDependencies": { "expo": "*" } }, "sha512-ucVcGPkvBrl2QHuy7XcYex2Y6BETvJ6TREutZrwLGUDnlvbpKS8KfQoNZOpvkyo5Nmm9RrasYQ0CrXmBHho2mg=="],
|
||||
|
||||
"expo-doctor": ["expo-doctor@1.19.9", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-SJW5HxEDQ9f5QdFvrUwfbdJZ4HI0EAAxsrJqrHBFjKBum+uSOcEIZPLRibwNQLTHOwTO1TWNLiMlF9sDUBWeYw=="],
|
||||
"expo-doctor": ["expo-doctor@1.19.7", "", { "bin": { "expo-doctor": "bin/expo-doctor.js" } }, "sha512-pzn7QtCifRlvGIQz8k7kszeYFaI5Yn81WTHlk/20tmd3jwnXxPjlcdyhFSkuRtO2v4a9gA/6aUWVBOosfffj9w=="],
|
||||
|
||||
"expo-file-system": ["expo-file-system@56.0.7", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA=="],
|
||||
|
||||
@@ -1599,7 +1599,7 @@
|
||||
|
||||
"react-native-text-ticker": ["react-native-text-ticker@1.15.0", "", {}, "sha512-d/uK+PIOhsYMy1r8h825iq/nADiHsabz3WMbRJSnkpQYn+K9aykUAXRRhu8ZbTAzk4CgnUWajJEFxS5ZDygsdg=="],
|
||||
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd", "sha512-vfkld2jUj7EPkAjIc/Vbx4Q4MtOOLmYtCYCE2dWJsyLnPqgj1f0xVzBxbeVP7dfT+eSh4KIXfdxESXaHgrXIlw=="],
|
||||
"react-native-track-player": ["react-native-track-player@github:lovegaoshi/react-native-track-player#33a3ecd", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-windows": "*", "shaka-player": "^4.7.9" }, "optionalPeers": ["react-native-windows", "shaka-player"] }, "lovegaoshi-react-native-track-player-33a3ecd"],
|
||||
|
||||
"react-native-udp": ["react-native-udp@4.1.7", "", { "dependencies": { "buffer": "^5.6.0", "events": "^3.1.0" } }, "sha512-NUE3zewu61NCdSsLlj+l0ad6qojcVEZPT4hVG/x6DU9U4iCzwtfZSASh9vm7teAcVzLkdD+cO3411LHshAi/wA=="],
|
||||
|
||||
|
||||
@@ -1,28 +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 } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
color={isWatchlisted ? "purple" : "white"}
|
||||
onPress={toggleWatchlist}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -89,14 +89,14 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Image
|
||||
source={require("@/assets/icons/seerr-logo.svg")}
|
||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Seerr</Text>
|
||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.jellyseerr_feature_description")}
|
||||
</Text>
|
||||
|
||||
@@ -29,7 +29,6 @@ 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";
|
||||
@@ -139,9 +138,6 @@ 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} />
|
||||
@@ -164,9 +160,6 @@ 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} />
|
||||
@@ -185,7 +178,6 @@ const ItemContentMobile: React.FC<ItemContentProps> = ({
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
settings.useKefinTweaks,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
TVRefreshButton,
|
||||
TVSeriesNavigation,
|
||||
TVTechnicalDetails,
|
||||
TVWatchlistButton,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -753,7 +752,6 @@ 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>
|
||||
|
||||
@@ -3,6 +3,11 @@ import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
MeasuredTriggerHost,
|
||||
OptionGroupCard,
|
||||
ToggleSwitch,
|
||||
} from "@/components/common/dropdownShared";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
@@ -10,7 +15,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
||||
// load and crashes the entire route tree on tvOS (expo-router requires every
|
||||
// route file). Load it lazily and only off-TV; TV never renders these.
|
||||
const { Button, Host, Menu } = Platform.isTV
|
||||
const { Button, Menu } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { disabled } = Platform.isTV
|
||||
@@ -66,16 +71,6 @@ interface PlatformDropdownProps {
|
||||
};
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
option,
|
||||
isLast,
|
||||
@@ -115,28 +110,15 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
};
|
||||
|
||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||
<View className='mb-6'>
|
||||
{group.title && (
|
||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||
{group.title}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||
>
|
||||
{group.options.map((option, index) => (
|
||||
<OptionItem
|
||||
key={index}
|
||||
option={option}
|
||||
isLast={index === group.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<OptionGroupCard title={group.title}>
|
||||
{group.options.map((option, index) => (
|
||||
<OptionItem
|
||||
key={index}
|
||||
option={option}
|
||||
isLast={index === group.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</OptionGroupCard>
|
||||
);
|
||||
|
||||
const BottomSheetContent: React.FC<{
|
||||
@@ -241,68 +223,42 @@ const PlatformDropdownComponent = ({
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
// @expo/ui's <Host> can't size to content, so an in-flow invisible copy of
|
||||
// the trigger sizes the wrapper while the Host overlays the real Menu.
|
||||
return (
|
||||
<View>
|
||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||
<Menu label={trigger}>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
const radioOptions = group.options.filter(
|
||||
(opt) => opt.type === "radio",
|
||||
) as RadioOption[];
|
||||
const toggleOptions = group.options.filter(
|
||||
(opt) => opt.type === "toggle",
|
||||
) as ToggleOption[];
|
||||
const actionOptions = group.options.filter(
|
||||
(opt) => opt.type === "action",
|
||||
) as ActionOption[];
|
||||
<MeasuredTriggerHost
|
||||
trigger={trigger}
|
||||
hostStyle={expoUIConfig?.hostStyle}
|
||||
>
|
||||
<Menu label={trigger}>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
const radioOptions = group.options.filter(
|
||||
(opt) => opt.type === "radio",
|
||||
) as RadioOption[];
|
||||
const toggleOptions = group.options.filter(
|
||||
(opt) => opt.type === "toggle",
|
||||
) as ToggleOption[];
|
||||
const actionOptions = group.options.filter(
|
||||
(opt) => opt.type === "action",
|
||||
) as ActionOption[];
|
||||
|
||||
const items = [];
|
||||
const items = [];
|
||||
|
||||
// Group radio options under a submenu ONLY if there's a title
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use a nested Menu as a submenu for grouped options. This
|
||||
// reads as "Title: Selected" and expands to the choices on
|
||||
// tap, keeping the nested look while staying a dropdown.
|
||||
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
||||
// would require a long-press and read as a context menu.)
|
||||
const selectedOption = radioOptions.find(
|
||||
(opt) => opt.selected,
|
||||
);
|
||||
const displayTitle = selectedOption
|
||||
? `${group.title}: ${selectedOption.label}`
|
||||
: group.title;
|
||||
items.push(
|
||||
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
||||
{radioOptions.map((option, optionIndex) => (
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
systemImage={
|
||||
option.selected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
modifiers={
|
||||
option.disabled ? [disabled(true)] : undefined
|
||||
}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Menu>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
radioOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
// Group radio options under a submenu ONLY if there's a title
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use a nested Menu as a submenu for grouped options. This
|
||||
// reads as "Title: Selected" and expands to the choices on
|
||||
// tap, keeping the nested look while staying a dropdown.
|
||||
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
||||
// would require a long-press and read as a context menu.)
|
||||
const selectedOption = radioOptions.find((opt) => opt.selected);
|
||||
const displayTitle = selectedOption
|
||||
? `${group.title}: ${selectedOption.label}`
|
||||
: group.title;
|
||||
items.push(
|
||||
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
||||
{radioOptions.map((option, optionIndex) => (
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
@@ -316,49 +272,67 @@ const PlatformDropdownComponent = ({
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Menu>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
radioOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
systemImage={
|
||||
option.selected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add Buttons for toggle options
|
||||
toggleOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
systemImage={
|
||||
option.value ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
// Add Buttons for toggle options
|
||||
toggleOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`toggle-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
systemImage={
|
||||
option.value ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
// Add Buttons for action options (no icon)
|
||||
actionOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`action-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
// Add Buttons for action options (no icon)
|
||||
actionOptions.forEach((option, optionIndex) => {
|
||||
items.push(
|
||||
<Button
|
||||
key={`action-${groupIndex}-${optionIndex}`}
|
||||
label={option.label}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return items;
|
||||
})}
|
||||
</Menu>
|
||||
</Host>
|
||||
</View>
|
||||
return items;
|
||||
})}
|
||||
</Menu>
|
||||
</MeasuredTriggerHost>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
<Image
|
||||
source={
|
||||
item.CriticRating < 60
|
||||
? require("@/assets/images/rt_rotten.svg")
|
||||
: require("@/assets/images/rt_fresh.svg")
|
||||
? require("@/assets/images/rotten-tomatoes.png")
|
||||
: require("@/assets/images/not-rotten-tomatoes.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
@@ -89,8 +89,8 @@ export const JellyserrRatings: React.FC<{
|
||||
className='mr-1'
|
||||
source={
|
||||
data?.criticsRating === "Rotten"
|
||||
? require("@/assets/images/rt_rotten.svg")
|
||||
: require("@/assets/images/rt_fresh.svg")
|
||||
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
@@ -109,8 +109,8 @@ export const JellyserrRatings: React.FC<{
|
||||
className='mr-1'
|
||||
source={
|
||||
data?.audienceRating === "Spilled"
|
||||
? require("@/assets/images/rt_aud_rotten.svg")
|
||||
: require("@/assets/images/rt_aud_fresh.svg")
|
||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
|
||||
}
|
||||
style={{
|
||||
width: 14,
|
||||
@@ -127,7 +127,7 @@ export const JellyserrRatings: React.FC<{
|
||||
iconLeft={
|
||||
<Image
|
||||
className='mr-1'
|
||||
source={require("@/assets/images/tmdb_logo.svg")}
|
||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
|
||||
@@ -69,23 +69,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
const isPresentedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else if (isPresentedRef.current) {
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
isPresentedRef.current = false;
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0) {
|
||||
isPresentedRef.current = true;
|
||||
} else if (index === -1 && isPresentedRef.current) {
|
||||
isPresentedRef.current = false;
|
||||
if (index === -1) {
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
<Text numberOfLines={1}>
|
||||
{selected === -1 && streamType === "Subtitle"
|
||||
? t("common.none")
|
||||
: selectedSteam?.DisplayTitle || t("common.select")}
|
||||
: selectedSteam?.DisplayTitle || t("common.select", "Select")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
@@ -39,7 +38,6 @@ function ChapterListComponent({
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeArea = useControlsSafeAreaInsets();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
@@ -76,22 +74,9 @@ function ChapterListComponent({
|
||||
transparent
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
// iOS defaults <Modal> to portrait-only; without this it rotates the app
|
||||
// back to portrait when opened from the landscape player. Android ignores it.
|
||||
supportedOrientations={["portrait", "landscape"]}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
marginLeft: safeArea.left,
|
||||
marginRight: safeArea.right,
|
||||
paddingBottom: safeArea.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
@@ -172,12 +157,14 @@ const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
@@ -11,10 +10,8 @@ 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;
|
||||
@@ -152,13 +149,10 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const segments = useSegments();
|
||||
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();
|
||||
@@ -187,66 +181,34 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
return;
|
||||
|
||||
// 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,
|
||||
},
|
||||
const options: string[] = [
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
];
|
||||
|
||||
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 = actions.findIndex((a) => a.destructive);
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex:
|
||||
destructiveButtonIndex === -1 ? undefined : destructiveButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
(selectedIndex) => {
|
||||
if (selectedIndex === undefined || selectedIndex >= actions.length)
|
||||
return;
|
||||
actions[selectedIndex].action();
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [
|
||||
@@ -254,13 +216,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
settings?.useKefinTweaks,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (
|
||||
|
||||
142
components/common/dropdownShared.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// Shared internals for PlatformDropdown and PlayerSettingsPopover.
|
||||
// Both components host SwiftUI content (Menu / Popover) inside @expo/ui's
|
||||
// <Host>, both render an Android bottom-sheet card for the same three core
|
||||
// option types (radio / toggle / action), and both wear the same wrapper
|
||||
// boilerplate. This module is the single source of truth for those pieces.
|
||||
//
|
||||
// What lives here:
|
||||
// - useTriggerSize() — measures the RN trigger's intrinsic size
|
||||
// - MeasuredTriggerHost — pins <Host> to that measured size (workaround
|
||||
// for @expo/ui SDK 55 sizing behaviour; see notes below)
|
||||
// - ToggleSwitch — the small purple switch used in the Android sheet
|
||||
// - OptionGroupCard — the rounded dark card with optional title that
|
||||
// wraps a group's option rows on Android
|
||||
//
|
||||
// What deliberately doesn't live here:
|
||||
// - The iOS rendering — PlatformDropdown uses a Menu, PlayerSettingsPopover
|
||||
// uses a hand-styled Popover. Nothing meaningful to share.
|
||||
// - The Android per-row renderers — PlatformDropdown handles 3 option types,
|
||||
// PlayerSettingsPopover handles 6 (adds slider/stepper/subgroup). Forcing
|
||||
// a shared abstraction would couple them. Each owns its own OptionItem.
|
||||
|
||||
import React, { useCallback, useState } from "react";
|
||||
import {
|
||||
type LayoutChangeEvent,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
||||
// load and crashes the entire route tree on tvOS. Load it lazily and only
|
||||
// off-TV; both consumers also gate rendering on Platform.OS === "ios".
|
||||
const { Host } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
|
||||
type TriggerSize = { width: number; height: number };
|
||||
|
||||
/**
|
||||
* Measures and remembers the intrinsic size of a RN trigger view so the
|
||||
* surrounding <Host> can be pinned to a concrete box.
|
||||
*
|
||||
* Returns `[size, handleLayout]` — pass `handleLayout` to a hidden,
|
||||
* absolutely-positioned mirror of the trigger and use `size` as the
|
||||
* wrapper's `style` once measured.
|
||||
*/
|
||||
export function useTriggerSize(): [
|
||||
TriggerSize | null,
|
||||
(e: LayoutChangeEvent) => void,
|
||||
] {
|
||||
const [size, setSize] = useState<TriggerSize | null>(null);
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
const { width, height } = e.nativeEvent.layout;
|
||||
setSize((prev) =>
|
||||
prev && prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height },
|
||||
);
|
||||
}, []);
|
||||
return [size, onLayout];
|
||||
}
|
||||
|
||||
interface MeasuredTriggerHostProps {
|
||||
trigger: React.ReactNode;
|
||||
hostStyle?: any;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins @expo/ui's <Host> to the trigger's measured size.
|
||||
*
|
||||
* @expo/ui's <Host> (SDK 55) fills its parent and reports its own size via
|
||||
* `setStyleSize`, so it can't size itself to content. If the wrapper has no
|
||||
* size, the Host's `flex: 1` height depends on the parent while the parent
|
||||
* depends on the Host — a circular dependency that collapses to 0 for any
|
||||
* dropdown nested more than one level deep (so only the first, shallowest
|
||||
* dropdown on screen stays visible).
|
||||
*
|
||||
* Giving the wrapper the measured trigger size breaks the cycle; the Host
|
||||
* then fills a concrete box.
|
||||
*/
|
||||
export const MeasuredTriggerHost: React.FC<MeasuredTriggerHostProps> = ({
|
||||
trigger,
|
||||
hostStyle,
|
||||
children,
|
||||
}) => {
|
||||
const [size, handleMeasure] = useTriggerSize();
|
||||
return (
|
||||
<View style={size ?? { opacity: 0 }}>
|
||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||
sizes to the trigger's content rather than to its parent. */}
|
||||
<View
|
||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||
pointerEvents='none'
|
||||
aria-hidden
|
||||
onLayout={handleMeasure}
|
||||
>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, hostStyle as any]}>
|
||||
{children}
|
||||
</Host>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/** Small pill switch used by Android sheet rows. */
|
||||
export const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
/**
|
||||
* Rounded dark card with an optional title above it. Wraps a group's option
|
||||
* rows in the Android bottom sheet.
|
||||
*/
|
||||
export const OptionGroupCard: React.FC<{
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, children }) => (
|
||||
<View className='mb-6'>
|
||||
{title && (
|
||||
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{ borderRadius: 12, overflow: "hidden" }}
|
||||
className='bg-neutral-800 rounded-xl overflow-hidden'
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { t } from "i18next";
|
||||
@@ -25,24 +22,7 @@ type FavoriteTypes =
|
||||
| "Playlist";
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
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?: string;
|
||||
}
|
||||
|
||||
export const Favorites = ({
|
||||
filter = "IsFavorite",
|
||||
queryKeyBase = "favorites",
|
||||
emptyTitleKey = "favorites.noDataTitle",
|
||||
emptyTextKey = "favorites.noData",
|
||||
seeAllNamespace = "favorites",
|
||||
}: FavoritesProps = {}) => {
|
||||
export const Favorites = () => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -66,7 +46,7 @@ export const Favorites = ({
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: [filter],
|
||||
filters: ["IsFavorite"],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -88,7 +68,7 @@ export const Favorites = ({
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user, filter],
|
||||
[api, user],
|
||||
);
|
||||
|
||||
// Reset empty state when component mounts or dependencies change
|
||||
@@ -146,68 +126,44 @@ export const Favorites = ({
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: {
|
||||
type: "Series",
|
||||
title: t(`${seeAllNamespace}.seeAllSeries`),
|
||||
filter,
|
||||
},
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
} as any);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: {
|
||||
type: "Movie",
|
||||
title: t(`${seeAllNamespace}.seeAllMovies`),
|
||||
filter,
|
||||
},
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
} as any);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: {
|
||||
type: "Episode",
|
||||
title: t(`${seeAllNamespace}.seeAllEpisodes`),
|
||||
filter,
|
||||
},
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
} as any);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: {
|
||||
type: "Video",
|
||||
title: t(`${seeAllNamespace}.seeAllVideos`),
|
||||
filter,
|
||||
},
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
} as any);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: {
|
||||
type: "BoxSet",
|
||||
title: t(`${seeAllNamespace}.seeAllBoxsets`),
|
||||
filter,
|
||||
},
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
} as any);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: {
|
||||
type: "Playlist",
|
||||
title: t(`${seeAllNamespace}.seeAllPlaylists`),
|
||||
filter,
|
||||
},
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
} as any);
|
||||
}, [router, filter, seeAllNamespace]);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
@@ -220,16 +176,16 @@ export const Favorites = ({
|
||||
source={heart}
|
||||
/>
|
||||
<Text className='text-xl font-semibold text-white mb-2'>
|
||||
{t(emptyTitleKey)}
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text className='text-base text-white/70 text-center max-w-xs px-4'>
|
||||
{t(emptyTextKey)}
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -237,7 +193,7 @@ export const Favorites = ({
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -246,7 +202,7 @@ export const Favorites = ({
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -254,7 +210,7 @@ export const Favorites = ({
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -262,7 +218,7 @@ export const Favorites = ({
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
@@ -270,7 +226,7 @@ export const Favorites = ({
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type {
|
||||
BaseItemKind,
|
||||
ItemFilter,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -12,12 +9,10 @@ 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;
|
||||
@@ -38,27 +33,7 @@ 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,
|
||||
@@ -78,7 +53,7 @@ export const Favorites = () => {
|
||||
userId: user?.Id,
|
||||
sortBy: ["SeriesSortName", "SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
filters: [filter],
|
||||
filters: ["IsFavorite"],
|
||||
recursive: true,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
collapseBoxSetItems: false,
|
||||
@@ -99,7 +74,7 @@ export const Favorites = () => {
|
||||
|
||||
return items;
|
||||
},
|
||||
[api, user, filter],
|
||||
[api, user],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +86,7 @@ export const Favorites = () => {
|
||||
BoxSet: false,
|
||||
Playlist: false,
|
||||
});
|
||||
}, [api, user, viewType]);
|
||||
}, [api, user]);
|
||||
|
||||
const areAllEmpty = () => {
|
||||
const loadedCategories = Object.values(emptyState);
|
||||
@@ -152,63 +127,46 @@ export const Favorites = () => {
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const tabBadges = (
|
||||
<TVFavoritesTabBadges
|
||||
viewType={viewType}
|
||||
setViewType={setViewType}
|
||||
enabled={watchlistEnabled}
|
||||
hasTVPreferredFocus={watchlistEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
if (areAllEmpty()) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
{tabBadges}
|
||||
<View
|
||||
<Image
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 64,
|
||||
height: 64,
|
||||
marginBottom: 16,
|
||||
tintColor: Colors.primary,
|
||||
}}
|
||||
contentFit='contain'
|
||||
source={heart}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{t("favorites.noDataTitle")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{t("favorites.noData")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -223,22 +181,17 @@ export const Favorites = () => {
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{watchlistEnabled && (
|
||||
<View style={{ paddingHorizontal: HORIZONTAL_PADDING }}>
|
||||
{tabBadges}
|
||||
</View>
|
||||
)}
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteSeries}
|
||||
queryKey={["home", queryKeyBase, "series"]}
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
isFirstSection={!watchlistEnabled}
|
||||
isFirstSection
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
queryKey={["home", queryKeyBase, "movies"]}
|
||||
queryKey={["home", "favorites", "movies"]}
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
@@ -246,28 +199,28 @@ export const Favorites = () => {
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", queryKeyBase, "episodes"]}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", queryKeyBase, "videos"]}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", queryKeyBase, "boxsets"]}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", queryKeyBase, "playlists"]}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
|
||||
@@ -133,6 +133,7 @@ const HomeMobile = () => {
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
|
||||
@@ -37,20 +37,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
return { ...item, People: people } as BaseItemDto;
|
||||
}, [item, people]);
|
||||
|
||||
// Jellyfin can list the same person several times (e.g. an actor also
|
||||
// credited as writer). Dedupe by Id so the same actor section isn't rendered
|
||||
// twice and we still surface 3 distinct people.
|
||||
const topPeople = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const unique: BaseItemPerson[] = [];
|
||||
for (const person of people) {
|
||||
if (!person.Id || seen.has(person.Id)) continue;
|
||||
seen.add(person.Id);
|
||||
unique.push(person);
|
||||
if (unique.length >= 3) break;
|
||||
}
|
||||
return unique;
|
||||
}, [people]);
|
||||
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||
|
||||
const renderActorSection = useCallback(
|
||||
(person: BaseItemPerson, idx: number, total: number) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from "i18next";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Pair with Phone */}
|
||||
{Platform.OS !== "ios" && onStartPairing && (
|
||||
{onStartPairing && (
|
||||
<View>
|
||||
<Button
|
||||
onPress={onStartPairing}
|
||||
|
||||
@@ -280,7 +280,7 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
<View style={styles.buttonContainer}>
|
||||
<TVSubmitButton
|
||||
onPress={handleSubmit}
|
||||
label={t("login.login_button")}
|
||||
label={t("login.login")}
|
||||
loading={isLoading}
|
||||
disabled={!password}
|
||||
/>
|
||||
|
||||
@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasMovies = movieResults && movieResults.length > 0;
|
||||
const hasTv = tvResults && tvResults.length > 0;
|
||||
const hasPersons = personResults && personResults.length > 0;
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||
keeps focus while typing, otherwise the first result would re-grab
|
||||
focus on every keystroke as results re-render. The user navigates
|
||||
down to the grid manually. */}
|
||||
<TVJellyseerrMovieSection
|
||||
title={t("search.request_movies")}
|
||||
items={movieResults}
|
||||
isFirstSection={false}
|
||||
isFirstSection={hasMovies}
|
||||
onItemPress={onMoviePress}
|
||||
/>
|
||||
<TVJellyseerrTvSection
|
||||
title={t("search.request_series")}
|
||||
items={tvResults}
|
||||
isFirstSection={false}
|
||||
isFirstSection={!hasMovies && hasTv}
|
||||
onItemPress={onTvPress}
|
||||
/>
|
||||
<TVJellyseerrPersonSection
|
||||
title={t("search.actors")}
|
||||
items={personResults}
|
||||
isFirstSection={false}
|
||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||
onItemPress={onPersonPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||
margins squeeze the bar's width and clip that trailing hint, so let
|
||||
the native view span the full width and own its own insets. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section) => (
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
// Never auto-focus a result. The native search field owns focus
|
||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||
// every keystroke as results re-render. User navigates down to the
|
||||
// grid manually.
|
||||
isFirstSection={false}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
|
||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible" }}
|
||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||
// contentOffset only applies on initial mount; since this FlatList is
|
||||
// reused across searches (stable key), a second search left the inset
|
||||
// without the offset and the grid snapped flush to the left edge.
|
||||
contentInset={{
|
||||
left: edgePadding,
|
||||
right: edgePadding,
|
||||
}}
|
||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: edgePadding,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -31,12 +31,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot, so refetches after
|
||||
// updateDownloadedItem() reflect the latest state instead of a stale
|
||||
// refreshKey-gated snapshot. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const router = useRouter();
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
@@ -104,7 +100,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
onPress={() => {
|
||||
router.setParams({ id: _item.Id });
|
||||
}}
|
||||
className={`flex flex-col w-44
|
||||
className={`flex flex-col w-44
|
||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
||||
/>
|
||||
</OptionGroup>
|
||||
|
||||
<OptionGroup title={t("library.options.options_title")}>
|
||||
<OptionGroup title='Options'>
|
||||
<ToggleItem
|
||||
label={t("library.options.show_titles")}
|
||||
value={settings.showTitles}
|
||||
|
||||
@@ -196,10 +196,7 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||
>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
|
||||
@@ -229,10 +229,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={
|
||||
!settings.autoPlayNextEpisode ||
|
||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||
}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Application from "expo-application";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getVersionInfo } from "@/utils/version";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -13,9 +13,10 @@ export const UserInfo: React.FC<Props> = ({ ...props }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Graduated build identifier — see utils/version.ts:
|
||||
// dev → "0.54.1 · branch · commit", develop/CI → "0.54.1 · commit", production → "0.54.1 (42)".
|
||||
const { display: version } = getVersionInfo();
|
||||
const version =
|
||||
Application?.nativeApplicationVersion ||
|
||||
Application?.nativeBuildVersion ||
|
||||
"N/A";
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
|
||||
@@ -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 } = useWatchlist(item);
|
||||
|
||||
return (
|
||||
<TVButton
|
||||
onPress={toggleWatchlist}
|
||||
variant='glass'
|
||||
square
|
||||
disabled={disabled}
|
||||
>
|
||||
<Ionicons
|
||||
name={isWatchlisted ? "bookmark" : "bookmark-outline"}
|
||||
size={28}
|
||||
color='#FFFFFF'
|
||||
/>
|
||||
</TVButton>
|
||||
);
|
||||
};
|
||||
@@ -68,5 +68,3 @@ export { TVTrackCard } from "./TVTrackCard";
|
||||
// User switching
|
||||
export type { TVUserCardProps } from "./TVUserCard";
|
||||
export { TVUserCard } from "./TVUserCard";
|
||||
export type { TVWatchlistButtonProps } from "./TVWatchlistButton";
|
||||
export { TVWatchlistButton } from "./TVWatchlistButton";
|
||||
|
||||
@@ -8,10 +8,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
@@ -75,6 +75,9 @@ interface BottomControlsProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
// Chapter props
|
||||
chapterPositions?: number[];
|
||||
}
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
@@ -108,10 +111,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
@@ -142,9 +146,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
right: insets.right,
|
||||
left: insets.left,
|
||||
bottom: Math.max(insets.bottom - 17, 0),
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
bottom:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? Math.max(insets.bottom - 17, 0)
|
||||
: 0,
|
||||
},
|
||||
]}
|
||||
className={"flex flex-col px-2"}
|
||||
@@ -180,6 +188,17 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
@@ -211,17 +230,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
onPress={handleNextEpisodeManual}
|
||||
/>
|
||||
)}
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center ml-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { FC } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
@@ -42,15 +42,15 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
goToNextChapter,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right: (settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -110,6 +110,10 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const [episodeView, setEpisodeView] = useState(false);
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
// Pause the controls auto-hide while the player settings popover is open so
|
||||
// it can't be dismissed out from under the user (notably the iOS popover,
|
||||
// which lives inside the controls and closes when they fade out).
|
||||
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
@@ -219,6 +223,7 @@ export const Controls: FC<Props> = ({
|
||||
hasNextChapter,
|
||||
goToPreviousChapter,
|
||||
goToNextChapter,
|
||||
chapterPositions,
|
||||
} = useChapterNavigation({
|
||||
chapters: item.Chapters,
|
||||
progress,
|
||||
@@ -365,9 +370,7 @@ export const Controls: FC<Props> = ({
|
||||
{ applyLanguagePreferences: true },
|
||||
);
|
||||
|
||||
// Use setParams instead of replace to avoid unmounting/remounting the player,
|
||||
// which would create a new MPV native view and crash with "mp_initialize already initialized".
|
||||
router.setParams({
|
||||
const queryParams = new URLSearchParams({
|
||||
...(offline && { offline: "true" }),
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
@@ -376,17 +379,11 @@ export const Controls: FC<Props> = ({
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
});
|
||||
}).toString();
|
||||
|
||||
router.replace(`player/direct-player?${queryParams}` as any);
|
||||
},
|
||||
[
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
offline,
|
||||
],
|
||||
[settings, subtitleIndex, audioIndex, mediaSource, bitrateValue, router],
|
||||
);
|
||||
|
||||
const goToPreviousItem = useCallback(() => {
|
||||
@@ -474,7 +471,7 @@ export const Controls: FC<Props> = ({
|
||||
episodeView,
|
||||
onHideControls: hideControls,
|
||||
timeout: CONTROLS_CONSTANTS.TIMEOUT,
|
||||
disabled: true,
|
||||
disabled: settingsMenuOpen,
|
||||
});
|
||||
|
||||
const switchOnEpisodeMode = useCallback(() => {
|
||||
@@ -535,6 +532,7 @@ export const Controls: FC<Props> = ({
|
||||
setPlaybackSpeed={setPlaybackSpeed}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
onToggleTechnicalInfo={onToggleTechnicalInfo}
|
||||
onSettingsMenuOpenChange={setSettingsMenuOpen}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
@@ -592,6 +590,7 @@ export const Controls: FC<Props> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
chapterPositions={chapterPositions}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
|
||||
@@ -1254,7 +1254,7 @@ export const Controls: FC<Props> = ({
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -1448,7 +1448,7 @@ export const Controls: FC<Props> = ({
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -16,10 +17,10 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
@@ -45,7 +46,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const isOffline = useOfflineMode();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -57,11 +59,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Read the live (cached) downloads DB inside the query rather than the
|
||||
// provider's downloadedItems snapshot. The snapshot only refreshes on the
|
||||
// provider refreshKey, so after updateDownloadedItem() invalidates
|
||||
// ["episodes"]/["seasons"] (e.g. progress/played writes) the refetch would
|
||||
// return stale data. getAllDownloadedItems() is cached, so this stays cheap.
|
||||
const { getDownloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
@@ -185,9 +182,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingTop:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
paddingLeft:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
paddingRight:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -5,11 +5,12 @@ import type {
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
@@ -36,6 +37,9 @@ interface HeaderControlsProps {
|
||||
// Technical info props
|
||||
showTechnicalInfo?: boolean;
|
||||
onToggleTechnicalInfo?: () => void;
|
||||
// Notifies the parent when the settings popover opens/closes so it can pause
|
||||
// the controls auto-hide while the menu is up.
|
||||
onSettingsMenuOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
@@ -56,9 +60,11 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
onSettingsMenuOpenChange,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const insets = useControlsSafeAreaInsets();
|
||||
const insets = useSafeAreaInsets();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
const { orientation, lockOrientation } = useOrientation();
|
||||
const [isTogglingOrientation, setIsTogglingOrientation] = useState(false);
|
||||
@@ -97,9 +103,10 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
style={[
|
||||
{
|
||||
position: "absolute",
|
||||
top: insets.top,
|
||||
left: insets.left,
|
||||
right: insets.right,
|
||||
top: (settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
@@ -114,6 +121,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
setPlaybackSpeed={setPlaybackSpeed}
|
||||
showTechnicalInfo={showTechnicalInfo}
|
||||
onToggleTechnicalInfo={onToggleTechnicalInfo}
|
||||
onOpenChange={onSettingsMenuOpenChange}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -16,8 +16,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
|
||||
type PlayMethod = "DirectPlay" | "DirectStream" | "Transcode";
|
||||
@@ -184,8 +184,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeInsets = useControlsSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
|
||||
const opacity = useSharedValue(0);
|
||||
@@ -268,8 +268,14 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
left: Math.max(insets.left, 48) + 20,
|
||||
}
|
||||
: {
|
||||
top: safeInsets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left: safeInsets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
top:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.top + HEADER_LAYOUT.CONTAINER_PADDING + 4
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 4,
|
||||
left:
|
||||
(settings?.safeAreaInControlsEnabled ?? true)
|
||||
? insets.left + HEADER_LAYOUT.CONTAINER_PADDING + 20
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV
|
||||
|
||||
@@ -3,10 +3,6 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
@@ -14,26 +10,18 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
import { PlaybackSpeedScope } from "../utils/playback-speed-settings";
|
||||
|
||||
// Subtitle scale presets (direct multiplier values)
|
||||
const SUBTITLE_SCALE_PRESETS = [
|
||||
{ label: "0.1x", value: 0.1 },
|
||||
{ label: "0.25x", value: 0.25 },
|
||||
{ label: "0.5x", value: 0.5 },
|
||||
{ label: "0.75x", value: 0.75 },
|
||||
{ label: "1.0x", value: 1.0 },
|
||||
{ label: "1.25x", value: 1.25 },
|
||||
{ label: "1.5x", value: 1.5 },
|
||||
{ label: "2.0x", value: 2.0 },
|
||||
{ label: "2.5x", value: 2.5 },
|
||||
{ label: "3.0x", value: 3.0 },
|
||||
] as const;
|
||||
import {
|
||||
type OptionGroup,
|
||||
PlayerSettingsPopover,
|
||||
} from "./PlayerSettingsPopover";
|
||||
|
||||
interface DropdownViewProps {
|
||||
playbackSpeed?: number;
|
||||
setPlaybackSpeed?: (speed: number, scope: PlaybackSpeedScope) => void;
|
||||
showTechnicalInfo?: boolean;
|
||||
onToggleTechnicalInfo?: () => void;
|
||||
/** Forwarded to the popover so the player can pause auto-hide while it's open. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const DropdownView = ({
|
||||
@@ -41,6 +29,7 @@ const DropdownView = ({
|
||||
setPlaybackSpeed,
|
||||
showTechnicalInfo = false,
|
||||
onToggleTechnicalInfo,
|
||||
onOpenChange,
|
||||
}: DropdownViewProps) => {
|
||||
const { subtitleTracks, audioTracks } = useVideoContext();
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
@@ -102,6 +91,7 @@ const DropdownView = ({
|
||||
if (!isOffline) {
|
||||
groups.push({
|
||||
title: "Quality",
|
||||
icon: "gauge.with.dots.needle.50percent",
|
||||
options:
|
||||
BITRATES?.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
@@ -113,29 +103,41 @@ const DropdownView = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Subtitle Section
|
||||
// Subtitles section. iOS: tap the `...` opens a SwiftUI Popover with the
|
||||
// section header "SUBTITLES" + a Track row (Menu) + a Size row (native
|
||||
// Slider). Android: same shape in a bottom-sheet — tap the "Track" row to
|
||||
// expand the list inline, Size shows a Material 3 Slider.
|
||||
if (subtitleTracks && subtitleTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Subtitles",
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
value: sub.index.toString(),
|
||||
selected: subtitleIndex === sub.index.toString(),
|
||||
onPress: () => sub.setTrack(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Subtitle Scale Section
|
||||
groups.push({
|
||||
title: "Subtitle Scale",
|
||||
options: SUBTITLE_SCALE_PRESETS.map((preset) => ({
|
||||
type: "radio" as const,
|
||||
label: preset.label,
|
||||
value: preset.value.toString(),
|
||||
selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value,
|
||||
onPress: () => updateSettings({ mpvSubtitleScale: preset.value }),
|
||||
})),
|
||||
options: [
|
||||
{
|
||||
type: "subgroup" as const,
|
||||
label: "Track",
|
||||
icon: "captions.bubble",
|
||||
options: subtitleTracks.map((sub) => ({
|
||||
type: "radio" as const,
|
||||
label: sub.name,
|
||||
value: sub.index.toString(),
|
||||
selected: subtitleIndex === sub.index.toString(),
|
||||
onPress: () => sub.setTrack(),
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: "slider" as const,
|
||||
label: "Size",
|
||||
icon: "textformat.size",
|
||||
value: Math.round((settings.mpvSubtitleScale ?? 1.0) * 10) / 10,
|
||||
step: 0.1,
|
||||
min: 0.1,
|
||||
max: 3.0,
|
||||
format: (v: number) => `${v.toFixed(1)}x`,
|
||||
onValueChange: (value: number) =>
|
||||
updateSettings({
|
||||
mpvSubtitleScale: Math.round(value * 10) / 10,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,6 +145,7 @@ const DropdownView = ({
|
||||
if (audioTracks && audioTracks.length > 0) {
|
||||
groups.push({
|
||||
title: "Audio",
|
||||
icon: "speaker.wave.2",
|
||||
options: audioTracks.map((track) => ({
|
||||
type: "radio" as const,
|
||||
label: track.name,
|
||||
@@ -157,6 +160,7 @@ const DropdownView = ({
|
||||
if (setPlaybackSpeed) {
|
||||
groups.push({
|
||||
title: "Speed",
|
||||
icon: "speedometer",
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
@@ -176,6 +180,7 @@ const DropdownView = ({
|
||||
label: showTechnicalInfo
|
||||
? "Hide Technical Info"
|
||||
: "Show Technical Info",
|
||||
icon: "info.circle",
|
||||
onPress: onToggleTechnicalInfo,
|
||||
},
|
||||
],
|
||||
@@ -216,11 +221,12 @@ const DropdownView = ({
|
||||
if (Platform.isTV) return null;
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
<PlayerSettingsPopover
|
||||
title='Playback Options'
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
expoUIConfig={{}}
|
||||
onOpenChange={onOpenChange}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
MeasuredTriggerHost,
|
||||
OptionGroupCard,
|
||||
ToggleSwitch,
|
||||
} from "@/components/common/dropdownShared";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import type {
|
||||
ActionOption as BaseActionOption,
|
||||
RadioOption as BaseRadioOption,
|
||||
ToggleOption as BaseToggleOption,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
// Player-only popover/sheet. Shares no rendering with `PlatformDropdown`:
|
||||
// that component is used by ~20 callers (settings, season pickers,
|
||||
// bitrate/audio/subtitle selectors, …) and must keep its small native
|
||||
// Menu look. This one targets the in-player `...` button and is allowed to
|
||||
// (a) host a real slider, (b) wear the Swift-mock visual style, and
|
||||
// (c) carry SF Symbol icons per row.
|
||||
//
|
||||
// Common boilerplate (trigger measurement, ToggleSwitch, Android option-card
|
||||
// shell) lives in @/components/common/dropdownShared and is reused with
|
||||
// PlatformDropdown.
|
||||
//
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
||||
// load and crashes the entire route tree on tvOS (expo-router requires every
|
||||
// route file). Load it lazily and only off-TV; TV never renders these.
|
||||
const {
|
||||
Button,
|
||||
HStack,
|
||||
Image: SwiftImage,
|
||||
Menu,
|
||||
Popover,
|
||||
Rectangle: SwiftRectangle,
|
||||
Slider: SwiftSlider,
|
||||
Spacer,
|
||||
Stepper,
|
||||
Text: SwiftText,
|
||||
Toggle: SwiftToggle,
|
||||
VStack,
|
||||
} = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const {
|
||||
buttonStyle,
|
||||
disabled,
|
||||
font,
|
||||
foregroundStyle,
|
||||
frame,
|
||||
opacity,
|
||||
padding,
|
||||
tint,
|
||||
} = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
// Android-side Material 3 slider. Lives in @expo/ui/community/slider and is a
|
||||
// drop-in for react-native-community/slider on Android (and SwiftUI Slider on
|
||||
// iOS, but we use the swift-ui Slider directly inside the popover instead).
|
||||
const { Slider: CommunitySlider } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/community/slider"))
|
||||
: require("@expo/ui/community/slider");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option model
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reuses PlatformDropdown's three base option types (so the 20+ shared callers
|
||||
// and the player popover stay in sync on shape), then adds:
|
||||
// - `icon?: string` on every variant — SF Symbol shown in the iOS popover
|
||||
// - Slider / Stepper / Subgroup variants for the player's extra controls
|
||||
|
||||
type WithIcon = { icon?: string };
|
||||
|
||||
export type RadioOption<T = any> = BaseRadioOption<T> & WithIcon;
|
||||
export type ToggleOption = BaseToggleOption & WithIcon;
|
||||
export type ActionOption = BaseActionOption & WithIcon;
|
||||
|
||||
export type StepperOption = {
|
||||
type: "stepper";
|
||||
label: string;
|
||||
value: number;
|
||||
step: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onValueChange: (value: number) => void;
|
||||
/** Optional value formatter for the displayed number. */
|
||||
format?: (value: number) => string;
|
||||
disabled?: boolean;
|
||||
} & WithIcon;
|
||||
|
||||
export type SliderOption = {
|
||||
type: "slider";
|
||||
label: string;
|
||||
value: number;
|
||||
step: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onValueChange: (value: number) => void;
|
||||
/** Optional value formatter for the displayed number. */
|
||||
format?: (value: number) => string;
|
||||
disabled?: boolean;
|
||||
} & WithIcon;
|
||||
|
||||
/**
|
||||
* A row that itself opens a nested dropdown. On iOS this renders as a
|
||||
* SwiftUI `Menu` inside the popover (label = subgroup name, value =
|
||||
* currently-selected child); on Android the row expands inline to show its
|
||||
* options when tapped (and collapses again on a second tap).
|
||||
*/
|
||||
export type SubgroupOption = {
|
||||
type: "subgroup";
|
||||
label: string;
|
||||
options: Option[];
|
||||
disabled?: boolean;
|
||||
} & WithIcon;
|
||||
|
||||
export type Option =
|
||||
| RadioOption
|
||||
| ToggleOption
|
||||
| ActionOption
|
||||
| StepperOption
|
||||
| SliderOption
|
||||
| SubgroupOption;
|
||||
|
||||
export type OptionGroup = {
|
||||
title?: string;
|
||||
options: Option[];
|
||||
/**
|
||||
* Optional SF Symbol used for the group's row in the iOS popover when the
|
||||
* entire group is compressed to a single Menu (e.g. radio-only groups).
|
||||
*/
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
interface PlayerSettingsPopoverProps {
|
||||
trigger?: React.ReactNode;
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
expoUIConfig?: {
|
||||
hostStyle?: any;
|
||||
};
|
||||
bottomSheetConfig?: {
|
||||
enableDynamicSizing?: boolean;
|
||||
enablePanDownToClose?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Android bottom-sheet renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const StepperControl: React.FC<{
|
||||
option: StepperOption;
|
||||
}> = ({ option }) => {
|
||||
const display = option.format
|
||||
? option.format(option.value)
|
||||
: option.value.toString();
|
||||
const canDecrement = option.value > option.min;
|
||||
const canIncrement = option.value < option.max;
|
||||
|
||||
const decrement = () => {
|
||||
if (option.disabled) return;
|
||||
const next = Math.max(option.min, option.value - option.step);
|
||||
if (next !== option.value) option.onValueChange(next);
|
||||
};
|
||||
const increment = () => {
|
||||
if (option.disabled) return;
|
||||
const next = Math.min(option.max, option.value + option.step);
|
||||
if (next !== option.value) option.onValueChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className='flex flex-row items-center'>
|
||||
<TouchableOpacity
|
||||
onPress={decrement}
|
||||
disabled={!canDecrement || option.disabled}
|
||||
className={`w-8 h-8 bg-neutral-700 rounded-l-lg flex items-center justify-center ${!canDecrement || option.disabled ? "opacity-40" : ""}`}
|
||||
>
|
||||
<Text className='text-white'>-</Text>
|
||||
</TouchableOpacity>
|
||||
<View className='h-8 px-3 bg-neutral-700 flex items-center justify-center'>
|
||||
<Text className='text-white'>{display}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={increment}
|
||||
disabled={!canIncrement || option.disabled}
|
||||
className={`w-8 h-8 bg-neutral-700 rounded-r-lg flex items-center justify-center ${!canIncrement || option.disabled ? "opacity-40" : ""}`}
|
||||
>
|
||||
<Text className='text-white'>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Android: full-width Material 3 slider inside the bottom sheet, with a
|
||||
* label/value row above the track. The slider lives below the touch target so
|
||||
* dragging it doesn't accidentally collapse the sheet.
|
||||
*/
|
||||
const SliderControl: React.FC<{
|
||||
option: SliderOption;
|
||||
}> = ({ option }) => {
|
||||
const display = option.format
|
||||
? option.format(option.value)
|
||||
: option.value.toString();
|
||||
return (
|
||||
<View className='flex-1 px-4 py-3'>
|
||||
<View className='flex flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-white'>{option.label}</Text>
|
||||
<Text className='text-neutral-400'>{display}</Text>
|
||||
</View>
|
||||
<CommunitySlider
|
||||
value={option.value}
|
||||
minimumValue={option.min}
|
||||
maximumValue={option.max}
|
||||
step={option.step}
|
||||
onValueChange={option.onValueChange}
|
||||
disabled={option.disabled}
|
||||
style={{ width: "100%", height: 40 }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
option,
|
||||
isLast,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isToggle = option.type === "toggle";
|
||||
const isAction = option.type === "action";
|
||||
const isStepper = option.type === "stepper";
|
||||
const isSlider = option.type === "slider";
|
||||
const isSubgroup = option.type === "subgroup";
|
||||
|
||||
if (isSlider) {
|
||||
return (
|
||||
<>
|
||||
<SliderControl option={option} />
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{ height: StyleSheet.hairlineWidth }}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePress = isToggle
|
||||
? option.onToggle
|
||||
: isSubgroup
|
||||
? () => setExpanded((v) => !v)
|
||||
: isStepper
|
||||
? undefined
|
||||
: (option as RadioOption | ActionOption).onPress;
|
||||
|
||||
const selectedChild = isSubgroup
|
||||
? (option.options.find(
|
||||
(o): o is RadioOption => o.type === "radio" && o.selected,
|
||||
) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={option.disabled || isStepper}
|
||||
activeOpacity={isStepper ? 1 : 0.2}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
<ToggleSwitch value={option.value} />
|
||||
) : isStepper ? (
|
||||
<StepperControl option={option} />
|
||||
) : isSubgroup ? (
|
||||
<View className='flex flex-row items-center'>
|
||||
{selectedChild && (
|
||||
<Text className='text-neutral-400 mr-2'>
|
||||
{selectedChild.label}
|
||||
</Text>
|
||||
)}
|
||||
<Ionicons
|
||||
name={expanded ? "chevron-up" : "chevron-down"}
|
||||
size={20}
|
||||
color='#9ca3af'
|
||||
/>
|
||||
</View>
|
||||
) : isAction ? null : (option as RadioOption).selected ? (
|
||||
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
||||
) : (
|
||||
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{isSubgroup && expanded && (
|
||||
<View className='pl-4 bg-neutral-900'>
|
||||
{option.options.map((child, childIndex) => (
|
||||
<OptionItem
|
||||
key={childIndex}
|
||||
option={child}
|
||||
isLast={childIndex === option.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLast && (
|
||||
<View
|
||||
style={{ height: StyleSheet.hairlineWidth }}
|
||||
className='bg-neutral-700 mx-4'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
||||
<OptionGroupCard title={group.title}>
|
||||
{group.options.map((option, index) => (
|
||||
<OptionItem
|
||||
key={index}
|
||||
option={option}
|
||||
isLast={index === group.options.length - 1}
|
||||
/>
|
||||
))}
|
||||
</OptionGroupCard>
|
||||
);
|
||||
|
||||
const BottomSheetContent: React.FC<{
|
||||
title?: string;
|
||||
groups: OptionGroup[];
|
||||
onOptionSelect?: (value?: any) => void;
|
||||
onClose?: () => void;
|
||||
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Recursively wrap options so radio/action presses also call
|
||||
// onOptionSelect/onClose, including options nested inside subgroups.
|
||||
const wrapOption = (option: Option): Option => {
|
||||
if (option.type === "radio") {
|
||||
return {
|
||||
...option,
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "toggle") {
|
||||
return {
|
||||
...option,
|
||||
onToggle: () => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "action") {
|
||||
return {
|
||||
...option,
|
||||
onPress: () => {
|
||||
option.onPress();
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
if (option.type === "subgroup") {
|
||||
return { ...option, options: option.options.map(wrapOption) };
|
||||
}
|
||||
return option;
|
||||
};
|
||||
|
||||
const wrappedGroups = groups.map((group) => ({
|
||||
...group,
|
||||
options: group.options.map(wrapOption),
|
||||
}));
|
||||
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
className='px-4 pb-8 pt-2'
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
||||
{wrappedGroups.map((group, index) => (
|
||||
<OptionGroupComponent key={index} group={group} />
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PlayerSettingsPopoverComponent = ({
|
||||
trigger,
|
||||
title,
|
||||
groups,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
onOptionSelect,
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlayerSettingsPopoverProps) => {
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Android: controlled open routes through the global bottom-sheet modal.
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true) {
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
onClose={() => {
|
||||
hideModal();
|
||||
controlledOnOpenChange?.(false);
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlledOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||
controlledOnOpenChange?.(false);
|
||||
}
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
// Internal open state for the iOS popover. Synced both ways with
|
||||
// `controlledOpen` when controlled.
|
||||
const [iosOpen, setIosOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "ios" && controlledOpen !== undefined) {
|
||||
setIosOpen(controlledOpen);
|
||||
}
|
||||
}, [controlledOpen]);
|
||||
|
||||
const handleIosOpenChange = (value: boolean) => {
|
||||
setIosOpen(value);
|
||||
controlledOnOpenChange?.(value);
|
||||
};
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
const closePopover = () => handleIosOpenChange(false);
|
||||
|
||||
// ---- Swift-mock styled popover body ----
|
||||
// Mirrors the reference Swift `PlayerSettingsViewController` design:
|
||||
// - small-caps section headers with a hairline rule to the trailing edge
|
||||
// - 44pt rows with leading SF Symbol, 15pt title, trailing value + glyph
|
||||
// - real native Slider rows for slider options
|
||||
// Radio-only titled groups (Quality/Audio/Speed) are compressed to a
|
||||
// single Menu row whose label is a styled HStack — tapping opens the
|
||||
// selection menu without changing the panel's height.
|
||||
|
||||
type IconName = string | undefined;
|
||||
|
||||
const MENU_CHEVRON = "chevron.up.chevron.down" as const;
|
||||
const TERTIARY = {
|
||||
type: "hierarchical" as const,
|
||||
style: "tertiary" as const,
|
||||
};
|
||||
const SECONDARY = {
|
||||
type: "hierarchical" as const,
|
||||
style: "secondary" as const,
|
||||
};
|
||||
|
||||
/** 24pt-wide leading icon slot. Renders a transparent placeholder when
|
||||
* no icon is set so titles stay aligned across rows. */
|
||||
const renderIcon = (icon: IconName) => (
|
||||
<SwiftImage
|
||||
systemName={(icon ?? "circle") as any}
|
||||
size={18}
|
||||
modifiers={[
|
||||
frame({ width: 24, alignment: "leading" }),
|
||||
foregroundStyle(SECONDARY),
|
||||
...(icon ? [] : [opacity(0)]),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Small-caps section header + thin separator that fills the row width. */
|
||||
const renderSectionHeader = (sectionTitle: string, key: string) => (
|
||||
<HStack
|
||||
key={key}
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 28 })]}
|
||||
>
|
||||
<SwiftText
|
||||
modifiers={[
|
||||
font({ size: 11, weight: "semibold" }),
|
||||
foregroundStyle(TERTIARY),
|
||||
]}
|
||||
>
|
||||
{sectionTitle.toUpperCase()}
|
||||
</SwiftText>
|
||||
<SwiftRectangle
|
||||
modifiers={[frame({ height: 1 }), foregroundStyle(TERTIARY)]}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
/** Bare hairline used to close out a multi-row titled section. */
|
||||
const renderDivider = (key: string) => (
|
||||
<SwiftRectangle
|
||||
key={key}
|
||||
modifiers={[
|
||||
frame({ height: 1 }),
|
||||
foregroundStyle(TERTIARY),
|
||||
padding({ vertical: 2 }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Render menu-safe children (radio/action) inside a SwiftUI Menu. */
|
||||
const renderMenuChild = (option: Option, key: string): any => {
|
||||
if (option.type === "radio") {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
label={option.label}
|
||||
systemImage={
|
||||
(option.selected ? "checkmark.circle.fill" : "circle") as any
|
||||
}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
closePopover();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (option.type === "action") {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
label={option.label}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
closePopover();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Row that opens a SwiftUI Menu on tap. Used for compressed radio
|
||||
* groups and for subgroup options inside a multi-row section. */
|
||||
const renderMenuRow = ({
|
||||
key,
|
||||
icon,
|
||||
title: rowTitle,
|
||||
valueLabel,
|
||||
children,
|
||||
}: {
|
||||
key: string;
|
||||
icon: IconName;
|
||||
title: string;
|
||||
valueLabel?: string;
|
||||
children: any;
|
||||
}) => (
|
||||
<Menu
|
||||
key={key}
|
||||
label={
|
||||
<HStack
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 44 })]}
|
||||
>
|
||||
{renderIcon(icon)}
|
||||
<SwiftText modifiers={[font({ size: 15 })]}>{rowTitle}</SwiftText>
|
||||
<Spacer />
|
||||
{valueLabel ? (
|
||||
<SwiftText
|
||||
modifiers={[font({ size: 13 }), foregroundStyle(SECONDARY)]}
|
||||
>
|
||||
{valueLabel}
|
||||
</SwiftText>
|
||||
) : null}
|
||||
<SwiftImage
|
||||
systemName={MENU_CHEVRON as any}
|
||||
size={12}
|
||||
modifiers={[foregroundStyle(TERTIARY)]}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const renderSliderRow = (option: SliderOption, key: string) => {
|
||||
const display = option.format
|
||||
? option.format(option.value)
|
||||
: option.value.toString();
|
||||
return (
|
||||
<HStack
|
||||
key={key}
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 44 })]}
|
||||
>
|
||||
{renderIcon(option.icon)}
|
||||
<SwiftText
|
||||
modifiers={[
|
||||
font({ size: 15 }),
|
||||
frame({ width: 64, alignment: "leading" }),
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</SwiftText>
|
||||
<SwiftSlider
|
||||
value={option.value}
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
step={option.step}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onValueChange={option.onValueChange}
|
||||
/>
|
||||
<SwiftText
|
||||
modifiers={[
|
||||
font({ size: 13, design: "monospaced" }),
|
||||
foregroundStyle(SECONDARY),
|
||||
frame({ width: 44, alignment: "trailing" }),
|
||||
]}
|
||||
>
|
||||
{display}
|
||||
</SwiftText>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStepperRow = (option: StepperOption, key: string) => {
|
||||
const display = option.format
|
||||
? option.format(option.value)
|
||||
: option.value.toString();
|
||||
return (
|
||||
<HStack
|
||||
key={key}
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 44 })]}
|
||||
>
|
||||
{renderIcon(option.icon)}
|
||||
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
|
||||
<Spacer />
|
||||
<Stepper
|
||||
label={display}
|
||||
value={option.value}
|
||||
step={option.step}
|
||||
min={option.min}
|
||||
max={option.max}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onValueChange={option.onValueChange}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const renderToggleRow = (option: ToggleOption, key: string) => (
|
||||
<HStack
|
||||
key={key}
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 44 })]}
|
||||
>
|
||||
{renderIcon(option.icon)}
|
||||
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
|
||||
<Spacer />
|
||||
<SwiftToggle
|
||||
label=''
|
||||
value={option.value}
|
||||
modifiers={option.disabled ? [disabled(true)] : undefined}
|
||||
onValueChange={() => {
|
||||
option.onToggle();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
const renderActionRow = (option: ActionOption, key: string) => (
|
||||
<Button
|
||||
key={key}
|
||||
modifiers={[
|
||||
buttonStyle("plain"),
|
||||
...(option.disabled ? [disabled(true)] : []),
|
||||
]}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
closePopover();
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 44 })]}
|
||||
>
|
||||
{renderIcon(option.icon)}
|
||||
<SwiftText modifiers={[font({ size: 15 })]}>{option.label}</SwiftText>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
</Button>
|
||||
);
|
||||
|
||||
/** Render one Option as its own row inside a mixed (non-compressed)
|
||||
* section. */
|
||||
const renderOptionRow = (option: Option, key: string): any => {
|
||||
if (option.type === "slider") return renderSliderRow(option, key);
|
||||
if (option.type === "stepper") return renderStepperRow(option, key);
|
||||
if (option.type === "toggle") return renderToggleRow(option, key);
|
||||
if (option.type === "action") return renderActionRow(option, key);
|
||||
if (option.type === "subgroup") {
|
||||
const selectedChild = option.options.find(
|
||||
(o): o is RadioOption => o.type === "radio" && o.selected,
|
||||
);
|
||||
return renderMenuRow({
|
||||
key,
|
||||
icon: option.icon,
|
||||
title: option.label,
|
||||
valueLabel: selectedChild?.label,
|
||||
children: option.options.map((child, idx) =>
|
||||
renderMenuChild(child, `${key}-c${idx}`),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (option.type === "radio") {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
modifiers={[
|
||||
buttonStyle("plain"),
|
||||
...(option.disabled ? [disabled(true)] : []),
|
||||
]}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
closePopover();
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
spacing={10}
|
||||
alignment='center'
|
||||
modifiers={[frame({ height: 44 })]}
|
||||
>
|
||||
{renderIcon(option.icon)}
|
||||
<SwiftText modifiers={[font({ size: 15 })]}>
|
||||
{option.label}
|
||||
</SwiftText>
|
||||
<Spacer />
|
||||
{option.selected ? (
|
||||
<SwiftImage
|
||||
systemName={"checkmark" as any}
|
||||
size={14}
|
||||
modifiers={[foregroundStyle(SECONDARY)]}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an entire OptionGroup.
|
||||
* - Titled group with only radio (or radio + action) options →
|
||||
* compressed to a single Menu row.
|
||||
* - Titled group containing slider/toggle/stepper/subgroup →
|
||||
* section header + individual rows.
|
||||
* - Untitled group → individual rows, no header.
|
||||
*/
|
||||
const renderGroup = (group: OptionGroup, groupIndex: number): any[] => {
|
||||
if (group.options.length === 0) return [];
|
||||
|
||||
const onlyMenuSafe = group.options.every(
|
||||
(o) => o.type === "radio" || o.type === "action",
|
||||
);
|
||||
|
||||
if (group.title && onlyMenuSafe) {
|
||||
const selectedRadio = group.options.find(
|
||||
(o): o is RadioOption => o.type === "radio" && o.selected,
|
||||
);
|
||||
return [
|
||||
renderMenuRow({
|
||||
key: `group-${groupIndex}`,
|
||||
icon: group.icon,
|
||||
title: group.title,
|
||||
valueLabel: selectedRadio?.label,
|
||||
children: group.options.map((opt, idx) =>
|
||||
renderMenuChild(opt, `g${groupIndex}-c${idx}`),
|
||||
),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const rows: any[] = [];
|
||||
if (group.title) {
|
||||
rows.push(renderSectionHeader(group.title, `header-${groupIndex}`));
|
||||
}
|
||||
group.options.forEach((opt, idx) => {
|
||||
rows.push(renderOptionRow(opt, `g${groupIndex}-o${idx}`));
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
return (
|
||||
<MeasuredTriggerHost
|
||||
trigger={trigger}
|
||||
hostStyle={expoUIConfig?.hostStyle}
|
||||
>
|
||||
<Popover
|
||||
isPresented={iosOpen}
|
||||
onIsPresentedChange={handleIosOpenChange}
|
||||
arrowEdge='top'
|
||||
>
|
||||
<Popover.Trigger>
|
||||
{/* Wrap the RN trigger view in a SwiftUI Button so tap handling
|
||||
is captured at the SwiftUI layer (matches the codebase
|
||||
pattern in SearchTabButtons.tsx). */}
|
||||
<Button
|
||||
modifiers={[buttonStyle("plain")]}
|
||||
onPress={() => handleIosOpenChange(true)}
|
||||
>
|
||||
{trigger}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content>
|
||||
{/* Bare VStack — no Form/List chrome — so the panel reads as
|
||||
the Swift mock's floating glass card. The popover itself
|
||||
supplies the material background; we just stack rows
|
||||
inside. Width pinned to ~320pt; height >= 480pt. */}
|
||||
<VStack
|
||||
spacing={0}
|
||||
alignment='leading'
|
||||
modifiers={[
|
||||
padding({ horizontal: 18, top: 12, bottom: 12 }),
|
||||
frame({
|
||||
minWidth: 300,
|
||||
idealWidth: 320,
|
||||
maxWidth: 360,
|
||||
minHeight: 480,
|
||||
idealHeight: 520,
|
||||
}),
|
||||
// Tint cascades to all child controls — Slider track, Menu
|
||||
// checkmark, Stepper ± buttons, Toggle — so one modifier
|
||||
// paints the whole popover white instead of system blue.
|
||||
tint("white"),
|
||||
]}
|
||||
>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
const rows = renderGroup(group, groupIndex);
|
||||
if (rows.length === 0) return [];
|
||||
// After a multi-row titled section (Subtitles), append a
|
||||
// bare hairline divider so it's clearly separated from
|
||||
// the next group below.
|
||||
const isMultiRow =
|
||||
!!group.title &&
|
||||
!group.options.every(
|
||||
(o) => o.type === "radio" || o.type === "action",
|
||||
);
|
||||
const hasNext = groupIndex < groups.length - 1;
|
||||
return isMultiRow && hasNext
|
||||
? [...rows, renderDivider(`footer-${groupIndex}`)]
|
||||
: rows;
|
||||
})}
|
||||
</VStack>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</MeasuredTriggerHost>
|
||||
);
|
||||
}
|
||||
|
||||
// Android: open the bottom sheet directly on press (uncontrolled mode).
|
||||
const handlePress = () => {
|
||||
showModal(
|
||||
<BottomSheetContent
|
||||
title={title}
|
||||
groups={groups}
|
||||
onOptionSelect={onOptionSelect}
|
||||
onClose={hideModal}
|
||||
/>,
|
||||
{
|
||||
snapPoints: ["90%"],
|
||||
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Memoize to prevent unnecessary re-renders when parent re-renders.
|
||||
export const PlayerSettingsPopover = React.memo(
|
||||
PlayerSettingsPopoverComponent,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.title === nextProps.title &&
|
||||
prevProps.open === nextProps.open &&
|
||||
prevProps.groups === nextProps.groups &&
|
||||
prevProps.trigger === nextProps.trigger,
|
||||
);
|
||||
15
eas.json
@@ -56,11 +56,7 @@
|
||||
"environment": "production",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"image": "latest",
|
||||
"config": "android-production.yml"
|
||||
},
|
||||
"ios": {
|
||||
"config": "ios-production.yml"
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
@@ -69,8 +65,7 @@
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest",
|
||||
"config": "android-production-apk.yml"
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk-tv": {
|
||||
@@ -79,8 +74,7 @@
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest",
|
||||
"config": "android-production-tv.yml"
|
||||
"image": "latest"
|
||||
},
|
||||
"env": {
|
||||
"EXPO_TV": "1"
|
||||
@@ -94,8 +88,7 @@
|
||||
"EXPO_TV": "1"
|
||||
},
|
||||
"ios": {
|
||||
"credentialsSource": "local",
|
||||
"config": "ios-production.yml"
|
||||
"credentialsSource": "local"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
type EdgeInsets,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/**
|
||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||
* returns zero insets so controls can sit flush against the screen edges.
|
||||
*/
|
||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
@@ -13,28 +12,36 @@ const useImageStorage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||
* resolves to an empty payload under RN's New Architecture.
|
||||
*/
|
||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
const tmpFile = new File(
|
||||
Paths.cache,
|
||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||
);
|
||||
let blob: Blob;
|
||||
try {
|
||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||
idempotent: true,
|
||||
});
|
||||
return await downloaded.base64();
|
||||
// Fetch the data from the URL
|
||||
const response = await fetch(url);
|
||||
blob = await response.blob();
|
||||
} catch (error) {
|
||||
console.warn("Error fetching image:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (tmpFile.exists) tmpFile.delete();
|
||||
}
|
||||
|
||||
// Create a FileReader instance
|
||||
const reader = new FileReader();
|
||||
|
||||
// Convert blob to base64
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Extract the base64 string (remove the data URL prefix)
|
||||
const base64 = reader.result.split(",")[1];
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveImage = useCallback(
|
||||
|
||||
@@ -109,35 +109,30 @@ export const usePlaybackManager = ({
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Derive prev/next from the current item's real position in the adjacent
|
||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||
* episode it can still return the current item as the first/last entry — so
|
||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||
*/
|
||||
const currentIndex = useMemo(
|
||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||
[adjacentItems, item],
|
||||
);
|
||||
|
||||
/** A neighbour is only navigable if it has an actual media file (not a
|
||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||
|
||||
const previousItem = useMemo(() => {
|
||||
if (!adjacentItems || currentIndex <= 0) return null;
|
||||
const candidate = adjacentItems[currentIndex - 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||
}
|
||||
|
||||
return adjacentItems[0];
|
||||
}, [adjacentItems, item]);
|
||||
|
||||
/** The next item in the series */
|
||||
const nextItem = useMemo(() => {
|
||||
if (!adjacentItems || currentIndex < 0) return null;
|
||||
const candidate = adjacentItems[currentIndex + 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||
}
|
||||
|
||||
return adjacentItems[2];
|
||||
}, [adjacentItems, item]);
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
|
||||
@@ -1,146 +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 { 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 itemId = item.Id ?? "";
|
||||
|
||||
// Get current watchlist status from shared state, falling back to item data
|
||||
const isWatchlisted = itemId
|
||||
? (watchlist[itemId] ?? item.UserData?.Likes)
|
||||
: item.UserData?.Likes;
|
||||
|
||||
// Update shared state when item data changes
|
||||
useEffect(() => {
|
||||
if (itemId && item.UserData?.Likes !== undefined) {
|
||||
setWatchlist((prev) => ({
|
||||
...prev,
|
||||
[itemId]: item.UserData!.Likes!,
|
||||
}));
|
||||
}
|
||||
}, [itemId, item.UserData?.Likes, setWatchlist]);
|
||||
|
||||
// Helper to update watchlist status in shared state
|
||||
const setIsWatchlisted = useCallback(
|
||||
(value: boolean | null | undefined) => {
|
||||
if (itemId && typeof value === "boolean") {
|
||||
setWatchlist((prev) => ({ ...prev, [itemId]: value }));
|
||||
}
|
||||
},
|
||||
[itemId, 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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: (_err, _nextIsWatchlisted, context) => {
|
||||
if (context?.previousQueries) {
|
||||
for (const [queryKey, data] of context.previousQueries) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
setIsWatchlisted(context?.previousIsWatchlisted);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: itemQueryKeyPrefix });
|
||||
queryClient.invalidateQueries({ queryKey: ["home", "watchlist"] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleWatchlist = useCallback(() => {
|
||||
watchlistMutation.mutate(!isWatchlisted);
|
||||
}, [watchlistMutation, isWatchlisted]);
|
||||
|
||||
return {
|
||||
isWatchlisted,
|
||||
toggleWatchlist,
|
||||
watchlistMutation,
|
||||
};
|
||||
};
|
||||
@@ -1,20 +1,46 @@
|
||||
apply plugin: 'expo-module-gradle-plugin'
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
}
|
||||
|
||||
group = 'expo.modules.backgrounddownloader'
|
||||
version = '1.0.0'
|
||||
|
||||
expoModule {
|
||||
canBePublished false
|
||||
}
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
apply from: expoModulesCorePlugin
|
||||
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useDefaultAndroidSdkVersions()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
android {
|
||||
namespace "expo.modules.backgrounddownloader"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "1.0.0"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ class MpvPlayerView: ExpoView {
|
||||
let onProgress = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
let onTracksReady = EventDispatcher()
|
||||
let onPictureInPictureChange = EventDispatcher()
|
||||
|
||||
private var currentURL: URL?
|
||||
private var cachedPosition: Double = 0
|
||||
@@ -82,6 +81,7 @@ class MpvPlayerView: ExpoView {
|
||||
private func setupView() {
|
||||
clipsToBounds = true
|
||||
backgroundColor = .black
|
||||
configureAudioSession()
|
||||
|
||||
videoContainer = UIView()
|
||||
videoContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -141,26 +141,21 @@ class MpvPlayerView: ExpoView {
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func configureAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .moviePlayback, policy: .longFormAudio, options: [])
|
||||
try session.setActive(true)
|
||||
try audioSession.setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback,
|
||||
policy: .longFormAudio,
|
||||
options: []
|
||||
)
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("Failed to configure audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate the session AND reset the category — `setActive(false)` alone
|
||||
/// leaves `.playback`/`.longFormAudio` on the shared singleton, so any later
|
||||
/// reactivation (foreground, route change, other modules) re-steals audio.
|
||||
private func tearDownAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
try? session.setCategory(.ambient, mode: .default, options: [.mixWithOthers])
|
||||
}
|
||||
// MARK: - Audio Session & Notifications
|
||||
|
||||
private func setupNotifications() {
|
||||
// Handle audio session interruptions (e.g., incoming calls, other apps playing audio)
|
||||
@@ -275,7 +270,6 @@ class MpvPlayerView: ExpoView {
|
||||
|
||||
func play() {
|
||||
intendedPlayState = true
|
||||
configureAudioSession()
|
||||
setupRemoteCommands()
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
@@ -446,7 +440,6 @@ class MpvPlayerView: ExpoView {
|
||||
renderer?.stop()
|
||||
displayLayer.removeFromSuperlayer()
|
||||
clearNowPlayingInfo()
|
||||
tearDownAudioSession()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
@@ -526,7 +519,9 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||
}
|
||||
|
||||
func renderer(_: MPVLayerRenderer, didSelectAudioOutput audioOutput: String) {
|
||||
print("[MPV] Audio output ready (\(audioOutput)), syncing Now Playing")
|
||||
// Audio output is now active - this is the right time to activate audio session and set Now Playing
|
||||
print("[MPV] Audio output ready (\(audioOutput)), activating audio session and syncing Now Playing")
|
||||
nowPlayingManager.activateAudioSession()
|
||||
syncNowPlaying(isPlaying: !isPaused())
|
||||
}
|
||||
}
|
||||
@@ -638,9 +633,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
print("PiP did start: \(didStartPictureInPicture)")
|
||||
// Ensure current time is synced when PiP starts
|
||||
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
|
||||
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
|
||||
// is `false` when AVKit reports a failure to start, so reflect that.
|
||||
onPictureInPictureChange(["isActive": didStartPictureInPicture])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
|
||||
@@ -659,9 +651,6 @@ extension MpvPlayerView: PiPControllerDelegate {
|
||||
if _isZoomedToFill {
|
||||
displayLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
// Notify JS that PiP has fully stopped so the controls overlay can
|
||||
// be re-mounted when the user returns to full screen.
|
||||
onPictureInPictureChange(["isActive": false])
|
||||
}
|
||||
|
||||
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MpvPlayerViewProps } from "./MpvPlayer.types";
|
||||
|
||||
export default function MpvPlayerView(props: MpvPlayerViewProps) {
|
||||
const url = props.source?.url ?? "";
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
title={t("player.mpv_player_title")}
|
||||
title='MPV Player'
|
||||
style={{ flex: 1 }}
|
||||
src={url}
|
||||
onLoad={() => props.onLoad?.({ nativeEvent: { url } })}
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
"lint": "biome check --write --unsafe --max-diagnostics 1000",
|
||||
"format": "biome format --write .",
|
||||
"doctor": "expo-doctor",
|
||||
"i18n:check": "bun scripts/check-i18n-keys.mjs",
|
||||
"i18n:fix-unused": "bun scripts/check-i18n-keys.mjs --fix-unused",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run i18n:check && bun run doctor",
|
||||
"test": "bun run typecheck && bun run lint && bun run format && bun run doctor",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -131,7 +129,7 @@
|
||||
"@types/react": "~19.2.10",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"cross-env": "10.1.0",
|
||||
"expo-doctor": "1.19.9",
|
||||
"expo-doctor": "1.19.7",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"react-test-renderer": "19.2.3",
|
||||
|
||||
@@ -4,16 +4,9 @@ const { withEntitlementsPlist } = require("expo/config-plugins");
|
||||
* Expo config plugin to add User Management entitlement for tvOS profile linking
|
||||
*/
|
||||
const withTVUserManagement = (config) => {
|
||||
// Only add for tvOS builds. The entitlement is restricted by Apple and must
|
||||
// be present in the provisioning profile, so injecting it into mobile builds
|
||||
// breaks signing ("Entitlement ... not found and could not be included in
|
||||
// profile"). The entitlement is only needed for tvOS
|
||||
// TVUserManager.currentUserIdentifier.
|
||||
if (process.env.EXPO_TV !== "1") {
|
||||
return config;
|
||||
}
|
||||
|
||||
return withEntitlementsPlist(config, (config) => {
|
||||
// Only add for tvOS builds (check if building for TV)
|
||||
// The entitlement is needed for TVUserManager.currentUserIdentifier to work
|
||||
config.modResults["com.apple.developer.user-management"] = [
|
||||
"runs-as-current-user",
|
||||
];
|
||||
|
||||