mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 13:08:33 +01:00
Compare commits
1 Commits
feat/setti
...
test/tvos-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd87cb05ea |
@@ -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
|
|
||||||
95
.github/pull_request_template.md
vendored
95
.github/pull_request_template.md
vendored
@@ -1,54 +1,91 @@
|
|||||||
<!--
|
<!--
|
||||||
Use a conventional commit title for the PR title,
|
Pull Request Template for Streamyfin
|
||||||
for example `feat(auth): add MFA`
|
====================================
|
||||||
All sections below are required. Write N/A if a section is not applicable.
|
Use this template to help reviewers understand the purpose of your PR
|
||||||
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
|
and to ensure all necessary checks are completed before merging.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# 📦 Pull Request
|
# 📦 Pull Request
|
||||||
|
|
||||||
<!--
|
## 🔖 Summary
|
||||||
🤖 AI ASSISTED?
|
|
||||||
Uncomment the line below if AI was used to assist with this PR:
|
|
||||||
-->
|
|
||||||
<!--
|
|
||||||
[](#) -->
|
|
||||||
|
|
||||||
## 📝 Description
|
|
||||||
<!--
|
<!--
|
||||||
A short description of the changes and why you're making them.
|
A concise description of the changes introduced by this PR.
|
||||||
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
Example:
|
||||||
|
“Add real-time currency conversion widget to dashboard.”
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
## 🏷️ Ticket / Issue
|
||||||
<!--
|
<!--
|
||||||
Link to the related ticket, issue or user story.
|
Link to the related ticket, issue or user story.
|
||||||
Example: Fixes #123
|
You can also indicate if this PR supersedes a previous one.
|
||||||
|
Example:
|
||||||
|
- Closes #123
|
||||||
|
- Fixes STREAMYFIN-456
|
||||||
|
- Resolves #789
|
||||||
|
- Supersedes #120
|
||||||
|
- Related: #130
|
||||||
-->
|
-->
|
||||||
|
|
||||||
### 🖼️ Screenshots / GIFs (if UI)
|
## 🛠️ What’s Changed
|
||||||
<!--
|
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
|
||||||
Include screenshots of relevant UI changes for both Android and iOS.
|
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
|
||||||
Before/After, responsive states (if relevant).
|
Spec: https://www.conventionalcommits.org/ -->
|
||||||
|
|
||||||
|
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
||||||
|
- Scope (optional): e.g., auth, billing, mobile
|
||||||
|
- Short summary: what changed and why (1–2 lines)
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## 📋 Details
|
||||||
|
<!--
|
||||||
|
Provide more context or background. Explain any non-obvious decisions.
|
||||||
|
Include screenshots or GIFs for UI changes if applicable.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### ⚠️ Breaking Changes
|
||||||
|
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
||||||
|
|
||||||
|
### 🔐 Security & Privacy Impact
|
||||||
|
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
|
||||||
|
|
||||||
|
### ⚡ Performance Impact
|
||||||
|
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
||||||
|
|
||||||
|
### 🖼️ Screenshots / GIFs (if UI)
|
||||||
|
<!-- Before/After, dark mode, responsive states. -->
|
||||||
|
|
||||||
## ✅ Checklist
|
## ✅ Checklist
|
||||||
<!--
|
<!--
|
||||||
Review and check off items as you complete them.
|
Review and check off items as you complete them.
|
||||||
-->
|
-->
|
||||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||||
- [ ] Verified that changes behave as expected for all platforms
|
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
||||||
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
- [ ] Type checks pass (tsc/biome/etc.)
|
||||||
- [ ] No secrets, hardcoded credentials, or private config files are included
|
- [ ] Docs updated (README/ADR/usage/API)
|
||||||
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
- [ ] No secrets/credentials included; env vars documented
|
||||||
|
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
||||||
|
- [ ] Verified locally that changes behave as expected
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
## 🔍 Testing Instructions
|
||||||
<!--
|
<!--
|
||||||
Describe how reviewers can test your changes. This will help the PR get merged faster.
|
Describe how reviewers can test your changes.
|
||||||
Example:
|
Example:
|
||||||
1. Open the settings page and scroll to the bottom
|
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
||||||
2. Verify that the clear data button is visible and pressable
|
2. Install deps: `npm|pnpm|yarn|bun install`
|
||||||
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
|
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
||||||
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
|
4. Run tests: `npm|pnpm|yarn|bun test`
|
||||||
|
5. Verification steps:
|
||||||
|
- [ ] Expected UI/endpoint behavior
|
||||||
|
- [ ] Logs show no errors
|
||||||
|
- [ ] Edge cases covered (list)
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## ⚙️ Deployment Notes
|
||||||
|
<!--
|
||||||
|
Describe any deployment considerations such as config, environment vars, or native builds.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 📝 Additional Notes
|
||||||
|
<!--
|
||||||
|
Any other information or references related to this PR.
|
||||||
|
-->
|
||||||
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@@ -25,25 +25,6 @@
|
|||||||
"osvVulnerabilityAlerts": true,
|
"osvVulnerabilityAlerts": true,
|
||||||
"configMigration": true,
|
"configMigration": true,
|
||||||
"separateMinorPatch": true,
|
"separateMinorPatch": true,
|
||||||
"customManagers": [
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
|
||||||
"matchStrings": [
|
|
||||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
|
||||||
],
|
|
||||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"customDatasources": {
|
|
||||||
"xcode": {
|
|
||||||
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
|
|
||||||
"format": "json",
|
|
||||||
"transformTemplates": [
|
|
||||||
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lockFileMaintenance": {
|
"lockFileMaintenance": {
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
27
.github/workflows/build-apps.yml
vendored
27
.github/workflows/build-apps.yml
vendored
@@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android-phone:
|
build-android-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: false # [tvos-test] temporarily disabled to only run tvOS builds
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🤖 Build Android APK (Phone)
|
name: 🤖 Build Android APK (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-android-tv:
|
build-android-tv:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: false # [tvos-test] temporarily disabled to only run tvOS builds
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
name: 🤖 Build Android APK (TV)
|
name: 🤖 Build Android APK (TV)
|
||||||
permissions:
|
permissions:
|
||||||
@@ -179,7 +179,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-ios-phone:
|
build-ios-phone:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: false # [tvos-test] temporarily disabled to only run tvOS builds
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone)
|
name: 🍎 Build iOS IPA (Phone)
|
||||||
permissions:
|
permissions:
|
||||||
@@ -218,8 +218,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -244,7 +243,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-ios-phone-unsigned:
|
build-ios-phone-unsigned:
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: false # [tvos-test] temporarily disabled to only run tvOS builds
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
name: 🍎 Build iOS IPA (Phone - Unsigned)
|
||||||
permissions:
|
permissions:
|
||||||
@@ -283,8 +282,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -304,7 +302,7 @@ jobs:
|
|||||||
build-ios-tv:
|
build-ios-tv:
|
||||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||||
# Re-enable by removing the `false &&` prefix below.
|
# Re-enable by removing the `false &&` prefix below.
|
||||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build tvOS IPA
|
name: 🍎 Build tvOS IPA
|
||||||
permissions:
|
permissions:
|
||||||
@@ -343,8 +341,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -369,9 +366,8 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
build-ios-tv-unsigned:
|
build-ios-tv-unsigned:
|
||||||
# Unsigned tvOS build is enabled (compiles without Apple credentials).
|
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||||
# The signed tvOS job above stays disabled until tvOS provisioning
|
# Re-enable by removing the `false &&` prefix below.
|
||||||
# profiles are set up in EAS (app + TopShelf targets).
|
|
||||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||||
runs-on: macos-26
|
runs-on: macos-26
|
||||||
name: 🍎 Build tvOS IPA (Unsigned)
|
name: 🍎 Build tvOS IPA (Unsigned)
|
||||||
@@ -411,8 +407,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
216
.github/workflows/release.yml
vendored
216
.github/workflows/release.yml
vendored
@@ -1,216 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
approve:
|
|
||||||
name: 🔐 Approve release
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
environment: production
|
|
||||||
steps:
|
|
||||||
- name: ✅ Release approved
|
|
||||||
run: echo "Release approved for ${{ github.sha }}"
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: 🚀 ${{ matrix.name }}
|
|
||||||
needs: approve
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: 🍎 iOS
|
|
||||||
platform: ios
|
|
||||||
profile: production
|
|
||||||
submit: true
|
|
||||||
- name: 📺 tvOS
|
|
||||||
platform: ios
|
|
||||||
profile: production_tv
|
|
||||||
submit: true
|
|
||||||
- name: 🤖 Android AAB
|
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
submodules: recursive
|
|
||||||
show-progress: false
|
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-cache
|
|
||||||
|
|
||||||
- name: 📦 Install dependencies and reload submodules
|
|
||||||
run: |
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
bun run submodule-reload
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
|
||||||
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.
|
|
||||||
- name: 🔐 Restore tvOS signing credentials
|
|
||||||
if: matrix.profile == 'production_tv'
|
|
||||||
env:
|
|
||||||
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
|
|
||||||
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
|
|
||||||
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
|
|
||||||
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
mkdir -p certs profiles
|
|
||||||
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
|
|
||||||
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
|
|
||||||
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).
|
|
||||||
- name: 🔐 Restore App Store Connect API key
|
|
||||||
if: matrix.platform == 'ios'
|
|
||||||
env:
|
|
||||||
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
|
|
||||||
run: |
|
|
||||||
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
|
|
||||||
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
|
|
||||||
else
|
|
||||||
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Submit builds: cloud build + auto-submit to the store ──
|
|
||||||
- name: 🚀 Build & submit (${{ matrix.name }})
|
|
||||||
if: matrix.submit
|
|
||||||
env:
|
|
||||||
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
||||||
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 }}
|
|
||||||
run: |
|
|
||||||
eas build \
|
|
||||||
--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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
||||||
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
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -18,9 +18,6 @@ web-build/
|
|||||||
/androidmobile
|
/androidmobile
|
||||||
/androidtv
|
/androidtv
|
||||||
|
|
||||||
# Gradle caches (top-level + per-module native projects)
|
|
||||||
**/.gradle/
|
|
||||||
|
|
||||||
# Module-specific Builds
|
# Module-specific Builds
|
||||||
modules/mpv-player/android/build
|
modules/mpv-player/android/build
|
||||||
modules/player/android
|
modules/player/android
|
||||||
@@ -76,12 +73,4 @@ modules/background-downloader/android/build/*
|
|||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
# but keep EAS custom build configs (the generic build/ rule above matches .eas/build/)
|
|
||||||
!.eas/build/
|
|
||||||
!.eas/build/**
|
|
||||||
.claude/
|
.claude/
|
||||||
.agents/skills/**
|
|
||||||
skills-lock.json
|
|
||||||
|
|
||||||
# CI-injected Google Play service account key (written at build time)
|
|
||||||
google-service-account.json
|
|
||||||
|
|||||||
14
app.json
14
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.54.1",
|
"version": "0.54.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"appleTeamId": "MWD5K362T8"
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"versionCode": 93,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
@@ -77,16 +78,15 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "16.4",
|
"deploymentTarget": "15.6",
|
||||||
"useFrameworks": "static",
|
"useFrameworks": "static"
|
||||||
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
||||||
"compileSdkVersion": 36,
|
"compileSdkVersion": 36,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.1.20",
|
"kotlinVersion": "2.0.21",
|
||||||
"minSdkVersion": 26,
|
"minSdkVersion": 26,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
@@ -143,8 +143,8 @@
|
|||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
"podName": "MPVKit",
|
"podName": "MPVKit-GPL",
|
||||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface MenuLink {
|
|||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomLinksPage() {
|
export default function menuLinks() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
|
|||||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function FavoritesPage() {
|
export default function favorites() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import useRouter from "@/hooks/useAppRouter";
|
|||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
@@ -48,7 +47,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -59,7 +66,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -89,7 +104,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -100,7 +123,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -111,7 +142,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -122,7 +161,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -133,7 +180,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -144,7 +199,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -174,7 +237,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -185,7 +256,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -196,7 +275,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -207,7 +294,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -218,7 +313,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -229,7 +332,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => _router.back()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -240,29 +351,15 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
}}
|
<Pressable
|
||||||
/>
|
onPress={() => _router.back()}
|
||||||
<Stack.Screen
|
className='pl-0.5'
|
||||||
name='settings/account/page'
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
options={{
|
>
|
||||||
title: t("home.settings.account.title"),
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
headerShown: !Platform.isTV,
|
</Pressable>
|
||||||
headerBlurEffect: "none",
|
),
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name='settings/notifications/page'
|
|
||||||
options={{
|
|
||||||
title: t("home.settings.notifications.title"),
|
|
||||||
headerShown: !Platform.isTV,
|
|
||||||
headerBlurEffect: "none",
|
|
||||||
headerTransparent: Platform.OS === "ios",
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerLeft: () => <HeaderBackButton />,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -272,7 +369,11 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerLeft: () => <HeaderBackButton />,
|
headerLeft: () => (
|
||||||
|
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BottomSheetModal } from "@gorhom/bottom-sheet";
|
||||||
BottomSheetBackdrop,
|
|
||||||
type BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -12,7 +7,6 @@ import { Alert, Platform, ScrollView, View } from "react-native";
|
|||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
|
||||||
@@ -26,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
|||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function DownloadsPage() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
@@ -107,7 +101,7 @@ export default function DownloadsPage() {
|
|||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => bottomSheetModalRef.current?.present()}
|
onPress={bottomSheetModalRef.current?.present}
|
||||||
className='px-2'
|
className='px-2'
|
||||||
>
|
>
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
@@ -122,7 +116,7 @@ export default function DownloadsPage() {
|
|||||||
}
|
}
|
||||||
}, [showMigration]);
|
}, [showMigration]);
|
||||||
|
|
||||||
const deleteMovies = () =>
|
const _deleteMovies = () =>
|
||||||
deleteFileByType("Movie")
|
deleteFileByType("Movie")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -133,7 +127,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
});
|
});
|
||||||
const deleteShows = () =>
|
const _deleteShows = () =>
|
||||||
deleteFileByType("Episode")
|
deleteFileByType("Episode")
|
||||||
.then(() =>
|
.then(() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -144,7 +138,7 @@ export default function DownloadsPage() {
|
|||||||
writeToLog("ERROR", reason);
|
writeToLog("ERROR", reason);
|
||||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
});
|
});
|
||||||
const deleteOtherMedia = () =>
|
const _deleteOtherMedia = () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
otherMedia
|
otherMedia
|
||||||
.filter((item) => item.item.Type)
|
.filter((item) => item.item.Type)
|
||||||
@@ -168,9 +162,6 @@ export default function DownloadsPage() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteAllMedia = async () =>
|
|
||||||
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={true}>
|
<OfflineModeProvider isOffline={true}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -265,42 +256,6 @@ export default function DownloadsPage() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className='p-4 space-y-4 mb-4'>
|
|
||||||
<Button color='purple' onPress={deleteMovies}>
|
|
||||||
{t("home.downloads.delete_all_movies_button")}
|
|
||||||
</Button>
|
|
||||||
<Button color='purple' onPress={deleteShows}>
|
|
||||||
{t("home.downloads.delete_all_tvseries_button")}
|
|
||||||
</Button>
|
|
||||||
{otherMedia.length > 0 && (
|
|
||||||
<Button color='purple' onPress={deleteOtherMedia}>
|
|
||||||
{t("home.downloads.delete_all_other_media_button")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button color='red' onPress={deleteAllMedia}>
|
|
||||||
{t("home.downloads.delete_all_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</OfflineModeProvider>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function page() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getProgressPercentage = () => {
|
const getProgressPercentage = () => {
|
||||||
if (!session.NowPlayingItem?.RunTimeTicks) {
|
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,38 @@
|
|||||||
import { useNavigation } from "expo-router";
|
import { useNavigation } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
import { SettingsHero } from "@/components/settings/index/SettingsHero";
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
import { SettingsRow } from "@/components/settings/index/SettingsRow";
|
|
||||||
import { SettingsSearchBar } from "@/components/settings/index/SettingsSearchBar";
|
|
||||||
import { SettingsSection } from "@/components/settings/index/SettingsSection";
|
|
||||||
import {
|
|
||||||
SETTINGS_CATALOG,
|
|
||||||
type SettingsTarget,
|
|
||||||
} from "@/components/settings/index/settingsCatalog";
|
|
||||||
import { useSettingsSearch } from "@/components/settings/index/useSettingsSearch";
|
|
||||||
import {
|
|
||||||
QuickConnectSheet,
|
|
||||||
type QuickConnectSheetRef,
|
|
||||||
} from "@/components/settings/QuickConnect";
|
|
||||||
import { StorageSettings } from "@/components/settings/StorageSettings";
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
// TV-specific settings component
|
||||||
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
|
||||||
|
|
||||||
|
// Mobile settings component
|
||||||
function SettingsMobile() {
|
function SettingsMobile() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_user] = useAtom(userAtom);
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const navigation = useNavigation();
|
|
||||||
const quickConnectRef = useRef<QuickConnectSheetRef>(null);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
||||||
const results = useSettingsSearch(query);
|
|
||||||
const searching = query.trim().length > 0;
|
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity onPress={() => logout()}>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text className='text-red-600 px-2'>
|
<Text className='text-red-600 px-2'>
|
||||||
{t("home.settings.log_out_button")}
|
{t("home.settings.log_out_button")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -47,95 +41,96 @@ function SettingsMobile() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTarget = (target: SettingsTarget) => {
|
|
||||||
if (target.type === "action") {
|
|
||||||
if (target.action === "quickConnect") {
|
|
||||||
quickConnectRef.current?.present();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(target.route as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
keyboardShouldPersistTaps='handled'
|
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingTop: 8,
|
|
||||||
paddingBottom: 32,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!searching && (
|
<View
|
||||||
<SettingsHero
|
className='p-4 flex flex-col'
|
||||||
onPress={() => router.push("/settings/account/page" as any)}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
/>
|
>
|
||||||
)}
|
<View className='mb-4'>
|
||||||
<SettingsSearchBar value={query} onChange={setQuery} />
|
<UserInfo />
|
||||||
|
</View>
|
||||||
|
|
||||||
{searching ? (
|
<QuickConnect className='mb-4' />
|
||||||
<SettingsSection title={t("home.settings.search_results")}>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<View className='px-4 py-3'>
|
|
||||||
<Text className='text-[#9899A1]'>
|
|
||||||
{t("home.settings.search_no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
results.map((r, i) => (
|
|
||||||
<SettingsRow
|
|
||||||
key={r.id}
|
|
||||||
title={r.title}
|
|
||||||
icon={r.icon}
|
|
||||||
value={r.subtitle}
|
|
||||||
onPress={() => handleTarget(r.target)}
|
|
||||||
isLast={i === results.length - 1}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View className='mx-3 mb-5'>
|
|
||||||
<AppLanguageSelector />
|
|
||||||
</View>
|
|
||||||
{SETTINGS_CATALOG.map((section) => {
|
|
||||||
const entries = section.entries.filter(
|
|
||||||
(e) => !e.platforms || e.platforms.includes(os),
|
|
||||||
);
|
|
||||||
if (entries.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<SettingsSection key={section.id} title={t(section.titleKey)}>
|
|
||||||
{entries.map((e, i) => (
|
|
||||||
<SettingsRow
|
|
||||||
key={e.id}
|
|
||||||
title={t(e.titleKey)}
|
|
||||||
icon={e.icon}
|
|
||||||
onPress={() => handleTarget(e.target)}
|
|
||||||
isLast={i === entries.length - 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SettingsSection>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<SettingsSection>
|
|
||||||
<View className='p-3'>
|
|
||||||
<StorageSettings />
|
|
||||||
</View>
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<QuickConnectSheet ref={quickConnectRef} />
|
<TouchableOpacity
|
||||||
|
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
|
||||||
|
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
|
||||||
|
>
|
||||||
|
<Text className='text-white font-bold text-base mb-1'>
|
||||||
|
{t("pairing.pair_with_phone_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-neutral-400 text-sm'>
|
||||||
|
{t("pairing.pair_with_phone_description")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View className='mb-4'>
|
||||||
|
<AppLanguageSelector />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='mb-4'>
|
||||||
|
<ListGroup title={t("home.settings.categories.title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/playback-controls/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.playback_controls.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/audio-subtitles/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.audio_subtitles.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/music/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.music.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/appearance/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.appearance.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/plugins/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.plugins.plugins_title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/intro/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.intro.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/network/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.network.title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.logs.logs_title")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<StorageSettings />
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
|
// Use TV settings component on TV platforms
|
||||||
if (Platform.isTV && SettingsTV) {
|
if (Platform.isTV && SettingsTV) {
|
||||||
return <SettingsTV />;
|
return <SettingsTV />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SettingsMobile />;
|
return <SettingsMobile />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import * as Application from "expo-application";
|
|
||||||
import { setStringAsync } from "expo-clipboard";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Alert, ScrollView } from "react-native";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
const success = useHaptic("success");
|
|
||||||
const version = Application.nativeApplicationVersion ?? "N/A";
|
|
||||||
const token = api?.accessToken ?? "";
|
|
||||||
const masked = token ? `•••• •••• •••• ${token.slice(-4)}` : "";
|
|
||||||
|
|
||||||
const copyToken = async () => {
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
await setStringAsync(token);
|
|
||||||
success();
|
|
||||||
Alert.alert(t("home.settings.account.copied"));
|
|
||||||
} catch {
|
|
||||||
Alert.alert(t("home.settings.account.copy_failed"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
||||||
<ListGroup title={t("home.settings.user_info.user_info_title")}>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.user")}
|
|
||||||
value={user?.Name}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.server")}
|
|
||||||
value={api?.basePath}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.token")}
|
|
||||||
value={revealed ? token : masked}
|
|
||||||
onPress={() => setRevealed((r) => !r)}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.account.copy_token")}
|
|
||||||
textColor='blue'
|
|
||||||
onPress={copyToken}
|
|
||||||
/>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.user_info.app_version")}
|
|
||||||
value={version}
|
|
||||||
/>
|
|
||||||
</ListGroup>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function AppearanceHideLibrariesPage() {
|
export default function page() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function HideLibrariesPage() {
|
export default function page() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import * as Notifications from "expo-notifications";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Linking, ScrollView, Switch, View } from "react-native";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const [perm, setPerm] =
|
|
||||||
useState<Notifications.NotificationPermissionsStatus | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Notifications.getPermissionsAsync().then(setPerm);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const requestPermission = async () => {
|
|
||||||
const res = await Notifications.requestPermissionsAsync();
|
|
||||||
setPerm(res);
|
|
||||||
// Only bounce to system settings when the OS will not prompt again.
|
|
||||||
if (!res.granted && res.canAskAgain === false) {
|
|
||||||
Linking.openSettings();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!settings || perm === null) return null;
|
|
||||||
|
|
||||||
if (!perm.granted) {
|
|
||||||
return (
|
|
||||||
<View className='flex-1 items-center justify-center px-8'>
|
|
||||||
<Ionicons name='notifications-off-outline' size={56} color='#5A5960' />
|
|
||||||
<Text className='text-white text-lg font-semibold mt-4 text-center'>
|
|
||||||
{t("home.settings.notifications.disabled_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-[#9899A1] text-center mt-2'>
|
|
||||||
{t("home.settings.notifications.disabled_description")}
|
|
||||||
</Text>
|
|
||||||
<Button color='purple' className='mt-6' onPress={requestPermission}>
|
|
||||||
{t("home.settings.notifications.enable_button")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
|
||||||
<ListGroup title={t("home.settings.notifications.events_title")}>
|
|
||||||
<ListItem title={t("home.settings.notifications.master")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.notificationsEnabled}
|
|
||||||
onValueChange={(v) => updateSettings({ notificationsEnabled: v })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
<ListItem title={t("home.settings.notifications.downloads")}>
|
|
||||||
<Switch
|
|
||||||
value={settings.notifyDownloads}
|
|
||||||
disabled={!settings.notificationsEnabled}
|
|
||||||
onValueChange={(v) => updateSettings({ notifyDownloads: v })}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -20,16 +20,18 @@ export default function PlaybackControlsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className='p-4'
|
className='p-4 flex flex-col'
|
||||||
style={{ gap: 16, paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
<MediaProvider>
|
<View className='mb-4'>
|
||||||
<MediaToggles />
|
<MediaProvider>
|
||||||
<GestureControls />
|
<MediaToggles className='mb-4' />
|
||||||
<PlaybackControlsSettings />
|
<GestureControls className='mb-4' />
|
||||||
<MpvBufferSettings />
|
<PlaybackControlsSettings />
|
||||||
<MpvVoSettings />
|
<MpvBufferSettings />
|
||||||
</MediaProvider>
|
<MpvVoSettings />
|
||||||
|
</MediaProvider>
|
||||||
|
</View>
|
||||||
{!Platform.isTV && <ChromecastSettings />}
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function JellyseerrPluginPage() {
|
export default function page() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function KefinTweaksPage() {
|
export default function page() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function MarlinSearchPage() {
|
export default function page() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
|
|||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function StreamystatsPage() {
|
export default function page() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshFromServer = useCallback(async () => {
|
const handleRefreshFromServer = useCallback(async () => {
|
||||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||||
// Update local state with new values
|
// Update local state with new values
|
||||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||||
setUrl(newUrl);
|
setUrl(newUrl);
|
||||||
|
|||||||
@@ -37,10 +37,8 @@ const Page: React.FC = () => {
|
|||||||
ItemFields.MediaStreams,
|
ItemFields.MediaStreams,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Lazily preload item with full media sources in background — never cache
|
// Lazily preload item with full media sources in background
|
||||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
|
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||||
gcTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
|
||||||
export default function JellyseerrCompanyPage() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
|
||||||
export default function JellyseerrGenrePage() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
@@ -77,7 +76,7 @@ const MobilePage: React.FC = () => {
|
|||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
|
||||||
const advancedReqModalRef = useRef<BottomSheetModalMethods>(null);
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
|
||||||
export default function JellyseerrPersonPage() {
|
export default function page() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createMaterialTopTabNavigator,
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "expo-router/js-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "@react-navigation/native";
|
||||||
|
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function LiveTvChannelsPage() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const _insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
|
|||||||
|
|
||||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||||
|
|
||||||
export default function LiveTvGuidePage() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
export default function LiveTvRecordingsPage() {
|
export default function page() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className='flex items-center justify-center h-full -mt-12'>
|
<View className='flex items-center justify-center h-full -mt-12'>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
|||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
@@ -105,10 +104,6 @@ const Page = () => {
|
|||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the library
|
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
|
||||||
useRefreshLibraryOnFocus();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function IndexLayout() {
|
|||||||
open={dropdownOpen}
|
open={dropdownOpen}
|
||||||
onOpenChange={setDropdownOpen}
|
onOpenChange={setDropdownOpen}
|
||||||
trigger={
|
trigger={
|
||||||
<View>
|
<View className='pl-1.5'>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='ellipsis-horizontal-outline'
|
name='ellipsis-horizontal-outline'
|
||||||
size={24}
|
size={24}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createMaterialTopTabNavigator,
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "expo-router/js-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
@@ -40,8 +40,6 @@ const Layout = () => {
|
|||||||
keyboardDismissMode='none'
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
tabBarActiveTintColor: "#FFFFFF",
|
|
||||||
tabBarInactiveTintColor: "#9CA3AF",
|
|
||||||
tabBarLabelStyle: {
|
tabBarLabelStyle: {
|
||||||
fontSize: TAB_LABEL_FONT_SIZE,
|
fontSize: TAB_LABEL_FONT_SIZE,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useNavigation, useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const exampleSearches = [
|
|||||||
"The Mandalorian",
|
"The Mandalorian",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function search() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -221,7 +221,7 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids?.length) {
|
if (!ids || !ids.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "@react-navigation/native";
|
||||||
|
import { withLayoutContext } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import { writeToLog } from "@/utils/log";
|
|||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||||
|
|
||||||
export default function DirectPlayerPage() {
|
export default function page() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -185,11 +185,11 @@ export default function DirectPlayerPage() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||||
|
|
||||||
// Initialize TV audio/subtitle indices from URL params.
|
// Initialize TV audio/subtitle indices from URL params
|
||||||
// No undefined guard: when a new episode's URL omits audioIndex, reset to
|
|
||||||
// undefined (media default) rather than leaking the previous episode's track.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentAudioIndex(audioIndex);
|
if (audioIndex !== undefined) {
|
||||||
|
setCurrentAudioIndex(audioIndex);
|
||||||
|
}
|
||||||
}, [audioIndex]);
|
}, [audioIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -274,11 +274,6 @@ export default function DirectPlayerPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (itemId) {
|
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();
|
fetchItemData();
|
||||||
}
|
}
|
||||||
}, [itemId, offline, api, user?.Id]);
|
}, [itemId, offline, api, user?.Id]);
|
||||||
@@ -321,14 +316,8 @@ export default function DirectPlayerPage() {
|
|||||||
return null;
|
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;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem?.mediaSource) {
|
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
if (item) {
|
if (item) {
|
||||||
result = {
|
result = {
|
||||||
@@ -399,7 +388,6 @@ export default function DirectPlayerPage() {
|
|||||||
item,
|
item,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
offline,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -482,11 +470,8 @@ export default function DirectPlayerPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ItemId: item.Id,
|
ItemId: item.Id,
|
||||||
// Report the live selection so server-side session/resume state reflects
|
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||||
// mid-playback track changes. Note: index 0 is valid (don't treat as
|
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||||
// falsy); -1 means "off" and is reported as-is.
|
|
||||||
AudioStreamIndex: currentAudioIndex,
|
|
||||||
SubtitleStreamIndex: currentSubtitleIndex,
|
|
||||||
MediaSourceId: mediaSourceId,
|
MediaSourceId: mediaSourceId,
|
||||||
PositionTicks: msToTicks(progress.get()),
|
PositionTicks: msToTicks(progress.get()),
|
||||||
IsPaused: !isPlaying,
|
IsPaused: !isPlaying,
|
||||||
@@ -500,8 +485,8 @@ export default function DirectPlayerPage() {
|
|||||||
}, [
|
}, [
|
||||||
stream,
|
stream,
|
||||||
item?.Id,
|
item?.Id,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
@@ -568,8 +553,8 @@ export default function DirectPlayerPage() {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
item?.Id,
|
item?.Id,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
stream,
|
stream,
|
||||||
@@ -837,10 +822,12 @@ export default function DirectPlayerPage() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** PiP handler for MPV */
|
||||||
const _onPictureInPictureChange = useCallback(
|
const _onPictureInPictureChange = useCallback(
|
||||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||||
const { isActive } = e.nativeEvent;
|
const { isActive } = e.nativeEvent;
|
||||||
setIsPipMode(isActive);
|
setIsPipMode(isActive);
|
||||||
|
// Hide controls when entering PiP
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
_setShowControls(false);
|
_setShowControls(false);
|
||||||
}
|
}
|
||||||
@@ -858,9 +845,6 @@ export default function DirectPlayerPage() {
|
|||||||
|
|
||||||
// Memoize video ref functions to prevent unnecessary re-renders
|
// Memoize video ref functions to prevent unnecessary re-renders
|
||||||
const startPictureInPicture = useCallback(async () => {
|
const startPictureInPicture = useCallback(async () => {
|
||||||
// Hide controls BEFORE entering PiP so the window captures a clean view
|
|
||||||
_setShowControls(false);
|
|
||||||
setIsPipMode(true);
|
|
||||||
return videoRef.current?.startPictureInPicture?.();
|
return videoRef.current?.startPictureInPicture?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1025,9 +1009,8 @@ export default function DirectPlayerPage() {
|
|||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(previousItem, settings, {
|
} = getDefaultPlaySettings(previousItem, settings, {
|
||||||
indexes: {
|
indexes: {
|
||||||
// Use the live selection, not the stale URL params (see goToNextItem).
|
subtitleIndex: subtitleIndex,
|
||||||
subtitleIndex: currentSubtitleIndex,
|
audioIndex: audioIndex,
|
||||||
audioIndex: currentAudioIndex,
|
|
||||||
},
|
},
|
||||||
source: stream?.mediaSource ?? undefined,
|
source: stream?.mediaSource ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -1046,8 +1029,8 @@ export default function DirectPlayerPage() {
|
|||||||
}, [
|
}, [
|
||||||
previousItem,
|
previousItem,
|
||||||
settings,
|
settings,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
@@ -1092,10 +1075,8 @@ export default function DirectPlayerPage() {
|
|||||||
subtitleIndex: defaultSubtitleIndex,
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
} = getDefaultPlaySettings(nextItem, settings, {
|
} = getDefaultPlaySettings(nextItem, settings, {
|
||||||
indexes: {
|
indexes: {
|
||||||
// Use the live selection (updated when the user changes tracks
|
subtitleIndex: subtitleIndex,
|
||||||
// mid-playback), not the stale URL params the episode started with.
|
audioIndex: audioIndex,
|
||||||
subtitleIndex: currentSubtitleIndex,
|
|
||||||
audioIndex: currentAudioIndex,
|
|
||||||
},
|
},
|
||||||
source: stream?.mediaSource ?? undefined,
|
source: stream?.mediaSource ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -1114,8 +1095,8 @@ export default function DirectPlayerPage() {
|
|||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
settings,
|
settings,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
@@ -1266,7 +1247,6 @@ export default function DirectPlayerPage() {
|
|||||||
nowPlayingMetadata={nowPlayingMetadata}
|
nowPlayingMetadata={nowPlayingMetadata}
|
||||||
onProgress={onProgress}
|
onProgress={onProgress}
|
||||||
onPlaybackStateChange={onPlaybackStateChanged}
|
onPlaybackStateChange={onPlaybackStateChanged}
|
||||||
onPictureInPictureChange={_onPictureInPictureChange}
|
|
||||||
onLoad={() => setIsVideoLoaded(true)}
|
onLoad={() => setIsVideoLoaded(true)}
|
||||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
|
|||||||
@@ -1248,7 +1248,7 @@ const styles = StyleSheet.create({
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
downloadingOverlay: {
|
downloadingOverlay: {
|
||||||
...StyleSheet.absoluteFill,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
borderRadius: scaleSize(14),
|
borderRadius: scaleSize(14),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import "@/augmentations";
|
|||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
@@ -395,9 +395,8 @@ function Layout() {
|
|||||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||||
dehydrateOptions: {
|
dehydrateOptions: {
|
||||||
shouldDehydrateQuery: (query) => {
|
shouldDehydrateQuery: (query) => {
|
||||||
return (
|
// Only persist successful queries
|
||||||
query.state.status === "success" && query.options.gcTime !== 0
|
return query.state.status === "success";
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
29
bun-patches/@react-native%2Fcodegen@0.83.6.patch
Normal file
29
bun-patches/@react-native%2Fcodegen@0.83.6.patch
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
diff --git a/lib/generators/modules/GenerateModuleObjCpp/index.js b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||||
|
index 927711514d2deaa3c795fb98e676e0a1f596eddc..0364d66204a76fccd3e06a0dc72bf801aa04a50d 100644
|
||||||
|
--- a/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||||
|
+++ b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||||
|
@@ -67,9 +67,12 @@ const HeaderFileTemplate = ({
|
||||||
|
* must have a single output. More files => more genrule()s => slower builds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
-#ifndef __cplusplus
|
||||||
|
-#error This file must be compiled as Obj-C++. If you are importing it, you must change your file extension to .mm.
|
||||||
|
-#endif
|
||||||
|
+// Patched: guard the Obj-C++ body with __cplusplus instead of hard-#error-ing.
|
||||||
|
+// With use_frameworks! :static + New Arch, plain Obj-C .m TUs can trigger a
|
||||||
|
+// Clang module build (via Swift-interop -Swift.h umbrellas) that pulls this
|
||||||
|
+// header in Obj-C mode. Skipping the body (instead of erroring) lets the module
|
||||||
|
+// build; .mm consumers still get the full Obj-C++ contents unchanged.
|
||||||
|
+#if defined(__cplusplus)
|
||||||
|
|
||||||
|
// Avoid multiple includes of ${headerFileNameWithNoExt} symbols
|
||||||
|
#ifndef ${headerFileNameWithNoExt}_H
|
||||||
|
@@ -93,7 +96,7 @@ const HeaderFileTemplate = ({
|
||||||
|
structInlineMethods +
|
||||||
|
(assumeNonnull ? '\nNS_ASSUME_NONNULL_END\n' : '\n') +
|
||||||
|
`#endif // ${headerFileNameWithNoExt}_H` +
|
||||||
|
- '\n'
|
||||||
|
+ '\n#endif // defined(__cplusplus)\n'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const SourceFileTemplate = ({headerFileName, moduleImplementations}) => `/**
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
|
||||||
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
--- a/ios/BottomAccessoryProvider.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
|
+++ b/ios/BottomAccessoryProvider.swift
|
||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
@@ -8,7 +8,7 @@ import SwiftUI
|
||||||
self.delegate = delegate
|
self.delegate = delegate
|
||||||
}
|
}
|
||||||
@@ -11,10 +14,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
|
|||||||
@available(iOS 26.0, *)
|
@available(iOS 26.0, *)
|
||||||
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
|
||||||
var placementValue = "none"
|
var placementValue = "none"
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
|
||||||
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
--- a/ios/TabView/NewTabView.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
+++ b/ios/TabView/NewTabView.swift
|
||||||
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +56,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
+
|
+
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
|
||||||
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
--- a/ios/TabViewImpl.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
|
+++ b/ios/TabViewImpl.swift
|
||||||
@@ -281,7 +281,7 @@ extension View {
|
@@ -281,7 +281,7 @@ extension View {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -66,10 +69,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
|
|||||||
if #available(iOS 26.0, macOS 26.0, *) {
|
if #available(iOS 26.0, macOS 26.0, *) {
|
||||||
if let behavior {
|
if let behavior {
|
||||||
self.tabBarMinimizeBehavior(behavior.convert())
|
self.tabBarMinimizeBehavior(behavior.convert())
|
||||||
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
|
||||||
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
|
||||||
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
--- a/ios/TabViewProps.swift
|
||||||
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
|
+++ b/ios/TabViewProps.swift
|
||||||
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
|
||||||
case onScrollUp
|
case onScrollUp
|
||||||
case onScrollDown
|
case onScrollDown
|
||||||
191
bun-patches/react-native-screens@4.18.0.patch
Normal file
191
bun-patches/react-native-screens@4.18.0.patch
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
diff --git a/node_modules/react-native-screens/.bun-tag-10a3b0add1bd4de6 b/.bun-tag-10a3b0add1bd4de6
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/node_modules/react-native-screens/.bun-tag-6a8504b742d5cfff b/.bun-tag-6a8504b742d5cfff
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/node_modules/react-native-screens/.bun-tag-d28396854bc27a3d b/.bun-tag-d28396854bc27a3d
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
|
||||||
|
index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
|
||||||
|
--- a/ios/RNSScreenStack.mm
|
||||||
|
+++ b/ios/RNSScreenStack.mm
|
||||||
|
@@ -34,6 +34,11 @@
|
||||||
|
#import "integrations/RNSDismissibleModalProtocol.h"
|
||||||
|
#import "utils/UINavigationBar+RNSUtility.h"
|
||||||
|
|
||||||
|
+#if TARGET_OS_TV
|
||||||
|
+#import <React/RCTTVNavigationEventNotification.h>
|
||||||
|
+#import <React/RCTTVRemoteHandler.h>
|
||||||
|
+#endif // TARGET_OS_TV
|
||||||
|
+
|
||||||
|
#ifdef RNS_GAMMA_ENABLED
|
||||||
|
#import "RNSFrameCorrectionProvider.h"
|
||||||
|
#import "Swift-Bridging.h"
|
||||||
|
@@ -43,6 +48,12 @@
|
||||||
|
namespace react = facebook::react;
|
||||||
|
#endif // RCT_NEW_ARCH_ENABLED
|
||||||
|
|
||||||
|
+#if TARGET_OS_TV
|
||||||
|
+@interface RNSNavigationController ()
|
||||||
|
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
|
||||||
|
+@end
|
||||||
|
+#endif // TARGET_OS_TV
|
||||||
|
+
|
||||||
|
@interface RNSScreenStackView () <
|
||||||
|
UINavigationControllerDelegate,
|
||||||
|
UIAdaptivePresentationControllerDelegate,
|
||||||
|
@@ -62,6 +73,57 @@ namespace react = facebook::react;
|
||||||
|
|
||||||
|
@implementation RNSNavigationController
|
||||||
|
|
||||||
|
+#if TARGET_OS_TV
|
||||||
|
+- (void)viewDidLoad
|
||||||
|
+{
|
||||||
|
+ [super viewDidLoad];
|
||||||
|
+
|
||||||
|
+ self.rnscreens_menuGestureRecognizer =
|
||||||
|
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
|
||||||
|
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
|
||||||
|
+
|
||||||
|
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||||
|
+ selector:@selector(rnscreens_enableMenuGesture)
|
||||||
|
+ name:RCTTVEnableMenuKeyNotification
|
||||||
|
+ object:nil];
|
||||||
|
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||||
|
+ selector:@selector(rnscreens_disableMenuGesture)
|
||||||
|
+ name:RCTTVDisableMenuKeyNotification
|
||||||
|
+ object:nil];
|
||||||
|
+
|
||||||
|
+ if ([RCTTVRemoteHandler useMenuKey]) {
|
||||||
|
+ [self rnscreens_enableMenuGesture];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)dealloc
|
||||||
|
+{
|
||||||
|
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rnscreens_enableMenuGesture
|
||||||
|
+{
|
||||||
|
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||||
|
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rnscreens_disableMenuGesture
|
||||||
|
+{
|
||||||
|
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||||
|
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
|
||||||
|
+{
|
||||||
|
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
|
||||||
|
+ keyAction:recognizer.eventKeyAction
|
||||||
|
+ tag:nil
|
||||||
|
+ target:nil];
|
||||||
|
+}
|
||||||
|
+#endif // TARGET_OS_TV
|
||||||
|
+
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
- (UIViewController *)childViewControllerForStatusBarStyle
|
||||||
|
{
|
||||||
|
diff --git a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||||
|
index 95c76ccf3528d3a8828e90b272a1d79b0828a139..f29d4df21440d23523ae7a2f6fe71c32154e3928 100644
|
||||||
|
--- a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||||
|
+++ b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||||
|
@@ -79,11 +79,13 @@ class RNSSplitViewAppearanceApplicator {
|
||||||
|
maxWidth: splitView.maximumSupplementaryColumnWidth)
|
||||||
|
|
||||||
|
#if compiler(>=6.2)
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
validateColumnConstraints(
|
||||||
|
minWidth: splitView.minimumInspectorColumnWidth,
|
||||||
|
maxWidth: splitView.maximumInspectorColumnWidth)
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Step 2.2 - applying updates to columns
|
||||||
|
@@ -126,6 +128,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6.2)
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
if splitView.minimumSecondaryColumnWidth >= 0 {
|
||||||
|
splitViewController.minimumSecondaryColumnWidth = splitView.minimumSecondaryColumnWidth
|
||||||
|
@@ -159,6 +162,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||||
|
splitView.preferredInspectorColumnWidthOrFraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Step 2.3 - manipulating with inspector column
|
||||||
|
diff --git a/ios/gamma/split-view/RNSSplitViewHostController.swift b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||||
|
index 0421e3ea92fc7bcdf57417b5ee3a62348fce34f5..cd878ab638d3c78a661e2df4c4c1b21011dfcf48 100644
|
||||||
|
--- a/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||||
|
+++ b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||||
|
@@ -386,7 +386,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
/// @param inspectors An array of inspector-type RNSSplitViewScreenComponentView subviews.
|
||||||
|
///
|
||||||
|
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
|
||||||
|
-
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
let inspector = inspectors.first
|
||||||
|
if inspector != nil {
|
||||||
|
@@ -395,6 +395,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
setViewController(inspectorViewController, for: .inspector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
@@ -404,9 +405,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
|
||||||
|
///
|
||||||
|
func maybeShowInspector() {
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
show(.inspector)
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
@@ -416,9 +419,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
|
||||||
|
///
|
||||||
|
func maybeHideInspector() {
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
hide(.inspector)
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
@@ -444,6 +449,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||||
|
public func splitViewController(
|
||||||
|
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
|
||||||
|
) {
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
|
||||||
|
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
|
||||||
|
@@ -461,6 +467,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
|
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
|
||||||
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
|
||||||
--- a/node_modules/react-native-udp/react-native-udp.podspec
|
--- a/react-native-udp.podspec
|
||||||
+++ b/node_modules/react-native-udp/react-native-udp.podspec
|
+++ b/react-native-udp.podspec
|
||||||
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
|
||||||
s.homepage = package_json["homepage"]
|
s.homepage = package_json["homepage"]
|
||||||
s.license = package_json["license"]
|
s.license = package_json["license"]
|
||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { type Href } from "expo-router";
|
import { type Href } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -200,30 +199,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const downloadDetailsPromises = items.map(async (item) => {
|
const downloadDetailsPromises = items.map(async (item) => {
|
||||||
// Ensure the snapshot we store offline carries the Chapters array.
|
|
||||||
// Page-level fetches sometimes use a fields filter that omits it; the
|
|
||||||
// offline player would then render no chapter ticks / list.
|
|
||||||
let itemForDownload = item;
|
|
||||||
if (!itemForDownload.Chapters && itemForDownload.Id) {
|
|
||||||
try {
|
|
||||||
const enriched = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: itemForDownload.Id,
|
|
||||||
userId: user.Id!,
|
|
||||||
});
|
|
||||||
if (enriched.data) {
|
|
||||||
itemForDownload = enriched.data;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mediaSource, audioIndex, subtitleIndex } =
|
const { mediaSource, audioIndex, subtitleIndex } =
|
||||||
itemsNotDownloaded.length > 1
|
itemsNotDownloaded.length > 1
|
||||||
? getDefaultPlaySettings(itemForDownload, settings!)
|
? getDefaultPlaySettings(item, settings!)
|
||||||
: {
|
: {
|
||||||
mediaSource: selectedOptions?.mediaSource,
|
mediaSource: selectedOptions?.mediaSource,
|
||||||
audioIndex: selectedOptions?.audioIndex,
|
audioIndex: selectedOptions?.audioIndex,
|
||||||
@@ -232,7 +210,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
const downloadDetails = await getDownloadUrl({
|
const downloadDetails = await getDownloadUrl({
|
||||||
api,
|
api,
|
||||||
item: itemForDownload,
|
item,
|
||||||
userId: user.Id!,
|
userId: user.Id!,
|
||||||
mediaSource: mediaSource!,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex ?? -1,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
@@ -244,7 +222,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url: downloadDetails?.url,
|
url: downloadDetails?.url,
|
||||||
item: itemForDownload,
|
item,
|
||||||
mediaSource: downloadDetails?.mediaSource,
|
mediaSource: downloadDetails?.mediaSource,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Host,
|
||||||
|
Menu,
|
||||||
|
Picker,
|
||||||
|
Text as SwiftUIText,
|
||||||
|
} from "@expo/ui/swift-ui";
|
||||||
|
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
|
import {
|
||||||
|
type LayoutChangeEvent,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||||
|
|
||||||
// @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, Host, Menu } = Platform.isTV
|
|
||||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
|
||||||
: require("@expo/ui/swift-ui");
|
|
||||||
const { disabled } = Platform.isTV
|
|
||||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
|
||||||
: require("@expo/ui/swift-ui/modifiers");
|
|
||||||
|
|
||||||
// Option types
|
// Option types
|
||||||
export type RadioOption<T = any> = {
|
export type RadioOption<T = any> = {
|
||||||
type: "radio";
|
type: "radio";
|
||||||
@@ -211,6 +214,24 @@ const PlatformDropdownComponent = ({
|
|||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
|
|
||||||
|
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
||||||
|
// `matchContents` doesn't help here: it reports the native Menu's size via
|
||||||
|
// setStyleSize and overrides any explicit size. Instead we measure the
|
||||||
|
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
||||||
|
const [triggerSize, setTriggerSize] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = e.nativeEvent.layout;
|
||||||
|
setTriggerSize((prev) =>
|
||||||
|
prev && prev.width === width && prev.height === height
|
||||||
|
? prev
|
||||||
|
: { width, height },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle controlled open state for Android
|
// Handle controlled open state for Android
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === "android" && controlledOpen === true) {
|
if (Platform.OS === "android" && controlledOpen === true) {
|
||||||
@@ -240,15 +261,25 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios") {
|
||||||
// @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 (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View pointerEvents='none' aria-hidden style={{ opacity: 0 }}>
|
{/* Hidden measurer: lays the trigger out normally to capture its
|
||||||
{trigger}
|
intrinsic size, which we then pin onto the Host below. */}
|
||||||
|
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
|
||||||
|
<View
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
onLayout={handleMeasureTrigger}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
<Host
|
||||||
|
style={[
|
||||||
|
triggerSize ?? { opacity: 0 },
|
||||||
|
expoUIConfig?.hostStyle as any,
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Menu label={trigger}>
|
<Menu label={trigger}>
|
||||||
{groups.flatMap((group, groupIndex) => {
|
{groups.flatMap((group, groupIndex) => {
|
||||||
// Check if this group has radio options
|
// Check if this group has radio options
|
||||||
@@ -264,40 +295,41 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
// Group radio options under a submenu ONLY if there's a title
|
// Add Picker for radio options ONLY if there's a group title
|
||||||
// Otherwise render as individual buttons
|
// Otherwise render as individual buttons
|
||||||
if (radioOptions.length > 0) {
|
if (radioOptions.length > 0) {
|
||||||
if (group.title) {
|
if (group.title) {
|
||||||
// Use a nested Menu as a submenu for grouped options. This
|
// Use Picker for grouped options.
|
||||||
// reads as "Title: Selected" and expands to the choices on
|
// Use the option index (a stable primitive) as the
|
||||||
// tap, keeping the nested look while staying a dropdown.
|
// tag/selection value and React key. Option `value`s can be
|
||||||
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
// objects (e.g. bitrate / media source), which collapse to
|
||||||
// would require a long-press and read as a context menu.)
|
// "[object Object]" as a key and never match the Picker's
|
||||||
const selectedOption = radioOptions.find(
|
// primitive selection.
|
||||||
|
const selectedRadioIndex = radioOptions.findIndex(
|
||||||
(opt) => opt.selected,
|
(opt) => opt.selected,
|
||||||
);
|
);
|
||||||
const displayTitle = selectedOption
|
|
||||||
? `${group.title}: ${selectedOption.label}`
|
|
||||||
: group.title;
|
|
||||||
items.push(
|
items.push(
|
||||||
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
<Picker
|
||||||
{radioOptions.map((option, optionIndex) => (
|
key={`picker-${groupIndex}`}
|
||||||
<Button
|
label={group.title}
|
||||||
|
selection={
|
||||||
|
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
|
||||||
|
}
|
||||||
|
onSelectionChange={(index) => {
|
||||||
|
const selectedOption = radioOptions[index as number];
|
||||||
|
selectedOption?.onPress();
|
||||||
|
onOptionSelect?.(selectedOption?.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{radioOptions.map((opt, optionIndex) => (
|
||||||
|
<SwiftUIText
|
||||||
key={`radio-${groupIndex}-${optionIndex}`}
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
label={option.label}
|
modifiers={[tag(optionIndex)]}
|
||||||
systemImage={
|
>
|
||||||
option.selected ? "checkmark.circle.fill" : "circle"
|
{opt.label}
|
||||||
}
|
</SwiftUIText>
|
||||||
modifiers={
|
|
||||||
option.disabled ? [disabled(true)] : undefined
|
|
||||||
}
|
|
||||||
onPress={() => {
|
|
||||||
option.onPress();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Menu>,
|
</Picker>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Render radio options as direct buttons
|
// Render radio options as direct buttons
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item?.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item?.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* A modal listing an item's chapters. Each row shows the chapter name and its
|
|
||||||
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
|
|
||||||
* chapter and closes the modal. Player-agnostic — the seek is injected.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { memo, useEffect, useMemo, useRef } from "react";
|
|
||||||
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,
|
|
||||||
formatChapterTime,
|
|
||||||
sortedChapters,
|
|
||||||
} from "@/utils/chapters";
|
|
||||||
|
|
||||||
interface ChapterListProps {
|
|
||||||
visible: boolean;
|
|
||||||
chapters: ChapterInfo[] | null | undefined;
|
|
||||||
/** Current playback position in milliseconds (to highlight the row). */
|
|
||||||
currentPositionMs: number;
|
|
||||||
/** Seek the player to this millisecond position. */
|
|
||||||
onSeek: (positionMs: number) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROW_HEIGHT = 48;
|
|
||||||
|
|
||||||
function ChapterListComponent({
|
|
||||||
visible,
|
|
||||||
chapters,
|
|
||||||
currentPositionMs,
|
|
||||||
onSeek,
|
|
||||||
onClose,
|
|
||||||
}: ChapterListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const safeArea = useControlsSafeAreaInsets();
|
|
||||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
|
||||||
|
|
||||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
|
||||||
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
|
|
||||||
// every tick — chapters is the only input that drives the underlying array.
|
|
||||||
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
|
|
||||||
const activeIndex = useMemo(() => {
|
|
||||||
let idx = -1;
|
|
||||||
for (let i = 0; i < starts.length; i++) {
|
|
||||||
if (currentPositionMs >= starts[i]) idx = i;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
return idx;
|
|
||||||
}, [currentPositionMs, starts]);
|
|
||||||
|
|
||||||
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
|
|
||||||
// children mounted across visible toggles, so subsequent opens never scroll.
|
|
||||||
// Trigger an imperative scroll each time the sheet becomes visible.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || activeIndex < 0 || entries.length === 0) return;
|
|
||||||
const raf = requestAnimationFrame(() => {
|
|
||||||
listRef.current?.scrollToIndex({
|
|
||||||
index: activeIndex,
|
|
||||||
animated: false,
|
|
||||||
viewPosition: 0.5,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(raf);
|
|
||||||
}, [visible, activeIndex, entries.length]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
|
||||||
<Pressable
|
|
||||||
onPress={onClose}
|
|
||||||
hitSlop={10}
|
|
||||||
accessibilityRole='button'
|
|
||||||
accessibilityLabel={t("chapters.close")}
|
|
||||||
>
|
|
||||||
<Ionicons name='close' size={24} color={Colors.text} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<FlatList
|
|
||||||
ref={listRef}
|
|
||||||
data={entries}
|
|
||||||
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
|
||||||
getItemLayout={(_, index) => ({
|
|
||||||
length: ROW_HEIGHT,
|
|
||||||
offset: ROW_HEIGHT * index,
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
onScrollToIndexFailed={(info) => {
|
|
||||||
// Required when getItemLayout is provided and the target index
|
|
||||||
// is outside the currently rendered window. Fallback to an
|
|
||||||
// offset-based scroll, then retry the precise scroll once a
|
|
||||||
// frame has elapsed.
|
|
||||||
listRef.current?.scrollToOffset({
|
|
||||||
offset: info.averageItemLength * info.index,
|
|
||||||
animated: false,
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
listRef.current?.scrollToIndex({
|
|
||||||
index: info.index,
|
|
||||||
animated: false,
|
|
||||||
viewPosition: 0.5,
|
|
||||||
});
|
|
||||||
}, 50);
|
|
||||||
}}
|
|
||||||
renderItem={({ item, index }) => {
|
|
||||||
const positionMs = item.positionMs;
|
|
||||||
const isActive = index === activeIndex;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSeek(positionMs);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
style={[
|
|
||||||
styles.row,
|
|
||||||
isActive && { backgroundColor: `${Colors.primary}33` },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.rowText,
|
|
||||||
{ color: isActive ? Colors.primary : Colors.text },
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.chapter.Name ||
|
|
||||||
t("chapters.chapter_number", { number: index + 1 })}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.rowTime}>
|
|
||||||
{formatChapterTime(positionMs)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChapterList = memo(ChapterListComponent);
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
backdrop: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
},
|
|
||||||
sheet: {
|
|
||||||
backgroundColor: Colors.background,
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
maxHeight: "70%",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: Colors.text,
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
height: ROW_HEIGHT,
|
|
||||||
},
|
|
||||||
rowText: {
|
|
||||||
fontSize: 15,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
rowTime: {
|
|
||||||
color: Colors.icon,
|
|
||||||
fontSize: 13,
|
|
||||||
marginLeft: 12,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chapter tick marks drawn as an absolute overlay over a progress slider.
|
|
||||||
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
|
|
||||||
* so the slider underneath still receives touches.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo, useState } from "react";
|
|
||||||
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
|
|
||||||
import type { ChapterMarker } from "@/utils/chapters";
|
|
||||||
|
|
||||||
interface ChapterTicksProps {
|
|
||||||
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
|
|
||||||
markers: ChapterMarker[];
|
|
||||||
/** Tick colour. */
|
|
||||||
color?: string;
|
|
||||||
/** Tick height in px — slightly less than the slider track thickness. */
|
|
||||||
height?: number;
|
|
||||||
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
|
|
||||||
width?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChapterTicksComponent({
|
|
||||||
markers,
|
|
||||||
// Semi-transparent black contrasts against both the filled progress
|
|
||||||
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
|
|
||||||
// stay visible across the whole bar as playback advances.
|
|
||||||
color = "rgba(0,0,0,0.55)",
|
|
||||||
height = 14,
|
|
||||||
width = 2,
|
|
||||||
}: ChapterTicksProps) {
|
|
||||||
// Hooks must run unconditionally — keep them before any early return.
|
|
||||||
const [sliderWidth, setSliderWidth] = useState(0);
|
|
||||||
|
|
||||||
const handleLayout = (e: LayoutChangeEvent) => {
|
|
||||||
setSliderWidth(e.nativeEvent.layout.width);
|
|
||||||
};
|
|
||||||
|
|
||||||
// One chapter (typically a single marker at 0) is not worth marking.
|
|
||||||
if (markers.length <= 1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
pointerEvents='none'
|
|
||||||
onLayout={handleLayout}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
// Let ticks taller than this container bleed beyond its bounds.
|
|
||||||
overflow: "visible",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sliderWidth > 0 &&
|
|
||||||
markers
|
|
||||||
// Skip the leading 0ms marker — it overlaps the slider start and
|
|
||||||
// adds visual noise at an already-rendered boundary.
|
|
||||||
.filter((marker) => marker.positionMs > 0)
|
|
||||||
.map((marker, index) => {
|
|
||||||
// Align both the position AND the width onto the device's
|
|
||||||
// physical pixel grid. Without this, fractional dp values land
|
|
||||||
// at different sub-pixel fractions per tick — Android samples
|
|
||||||
// each one differently and some ticks render visibly thicker.
|
|
||||||
const centerDp = (marker.percent / 100) * sliderWidth;
|
|
||||||
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
|
|
||||||
const snappedWidth = PixelRatio.roundToNearestPixel(width);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={`${marker.positionMs}-${index}`}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left,
|
|
||||||
top: "50%",
|
|
||||||
marginTop: -height / 2,
|
|
||||||
height,
|
|
||||||
width: snappedWidth,
|
|
||||||
backgroundColor: color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChapterTicks = memo(ChapterTicksComponent);
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
|
|
||||||
interface InputProps extends TextInputProps {
|
interface InputProps extends TextInputProps {
|
||||||
extraClassName?: string;
|
extraClassName?: string;
|
||||||
@@ -21,9 +20,6 @@ export function Input(props: InputProps) {
|
|||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
// TV-only: scales the input font with the tvTypographyScale setting.
|
|
||||||
// Not consumed by the mobile branch below.
|
|
||||||
const tvTypography = useScaledTVTypography();
|
|
||||||
|
|
||||||
const animateFocus = (focused: boolean) => {
|
const animateFocus = (focused: boolean) => {
|
||||||
Animated.timing(scale, {
|
Animated.timing(scale, {
|
||||||
@@ -45,18 +41,8 @@ export function Input(props: InputProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// Scale the whole input (box height, padding, icon) proportionally with the
|
|
||||||
// font so the component grows/shrinks with the tvTypographyScale setting.
|
|
||||||
// Uses the `body` token (primary reading size); it resolves to 28 at Default.
|
|
||||||
const fontSize = tvTypography.body;
|
|
||||||
const factor = fontSize / 28;
|
|
||||||
const height = Math.round(56 * factor);
|
|
||||||
const paddingLeft = Math.round(24 * factor);
|
|
||||||
const iconSize = Math.round(26 * factor);
|
|
||||||
const iconMarginRight = Math.round(14 * factor);
|
|
||||||
|
|
||||||
const containerStyle = {
|
const containerStyle = {
|
||||||
height,
|
height: 48,
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
borderWidth: isFocused ? 1.5 : 1,
|
borderWidth: isFocused ? 1.5 : 1,
|
||||||
borderColor: isFocused
|
borderColor: isFocused
|
||||||
@@ -65,16 +51,16 @@ export function Input(props: InputProps) {
|
|||||||
overflow: "hidden" as const,
|
overflow: "hidden" as const,
|
||||||
flexDirection: "row" as const,
|
flexDirection: "row" as const,
|
||||||
alignItems: "center" as const,
|
alignItems: "center" as const,
|
||||||
paddingLeft,
|
paddingLeft: 16,
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputElement = (
|
const inputElement = (
|
||||||
<>
|
<>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='search'
|
name='search'
|
||||||
size={iconSize}
|
size={20}
|
||||||
color={isFocused ? "#999" : "#666"}
|
color={isFocused ? "#999" : "#666"}
|
||||||
style={{ marginRight: iconMarginRight }}
|
style={{ marginRight: 12 }}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -83,8 +69,8 @@ export function Input(props: InputProps) {
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height,
|
height: 48,
|
||||||
fontSize,
|
fontSize: 18,
|
||||||
fontWeight: "400",
|
fontWeight: "400",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
|
|||||||
@@ -37,12 +37,11 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={
|
style={{
|
||||||
Platform.isTV
|
width: `${progress}%`,
|
||||||
? { width: `${progress}%`, backgroundColor: "#ffffff" }
|
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
|
||||||
: { width: `${progress}%` }
|
}}
|
||||||
}
|
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
|
||||||
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useSegments } from "expo-router";
|
import { useSegments } from "expo-router";
|
||||||
import { type PropsWithChildren, useCallback } from "react";
|
import { type PropsWithChildren, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -150,7 +149,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||||
@@ -184,13 +182,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
const options: string[] = [
|
const options: string[] = [
|
||||||
t("common.mark_as_played"),
|
"Mark as Played",
|
||||||
t("common.mark_as_not_played"),
|
"Mark as Not Played",
|
||||||
isFavorite
|
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||||
? t("music.track_options.remove_from_favorites")
|
...(isOffline ? ["Delete Download"] : []),
|
||||||
: t("music.track_options.add_to_favorites"),
|
"Cancel",
|
||||||
...(isOffline ? [t("home.downloads.delete_download")] : []),
|
|
||||||
t("common.cancel"),
|
|
||||||
];
|
];
|
||||||
const cancelButtonIndex = options.length - 1;
|
const cancelButtonIndex = options.length - 1;
|
||||||
const destructiveButtonIndex = isOffline
|
const destructiveButtonIndex = isOffline
|
||||||
@@ -223,7 +219,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
isOffline,
|
isOffline,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
item.Id,
|
item.Id,
|
||||||
t,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}, [process?.progress]);
|
}, [process?.progress]);
|
||||||
|
|
||||||
// Return null after all hooks have been called
|
// Return null after all hooks have been called
|
||||||
if (!process?.item?.Id) {
|
if (!process || !process.item || !process.item.Id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
@@ -90,10 +89,6 @@ const HomeMobile = () => {
|
|||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
|
||||||
useRefreshLibraryOnFocus();
|
|
||||||
|
|
||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
@@ -133,6 +128,7 @@ const HomeMobile = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
|
className='ml-1.5'
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
>
|
>
|
||||||
<Feather
|
<Feather
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import {
|
import {
|
||||||
@@ -87,10 +86,6 @@ export const Home = () => {
|
|||||||
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const { showItemActions } = useTVItemActionModal();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
// Fallback refresh for newly added content when returning to the home screen
|
|
||||||
// (primary path is the LibraryChanged WebSocket event).
|
|
||||||
useRefreshLibraryOnFocus();
|
|
||||||
|
|
||||||
// Dynamic backdrop state with debounce
|
// Dynamic backdrop state with debounce
|
||||||
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
|
|
||||||
// Get subtitle for episodes
|
// Get subtitle for episodes
|
||||||
const episodeSubtitle = useMemo(() => {
|
const episodeSubtitle = useMemo(() => {
|
||||||
if (activeItem?.Type !== "Episode") return null;
|
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||||
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||||
}, [activeItem]);
|
}, [activeItem]);
|
||||||
|
|
||||||
|
|||||||
@@ -37,20 +37,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
return { ...item, People: people } as BaseItemDto;
|
return { ...item, People: people } as BaseItemDto;
|
||||||
}, [item, people]);
|
}, [item, people]);
|
||||||
|
|
||||||
// Jellyfin can list the same person several times (e.g. an actor also
|
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||||
// 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 renderActorSection = useCallback(
|
const renderActorSection = useCallback(
|
||||||
(person: BaseItemPerson, idx: number, total: number) => {
|
(person: BaseItemPerson, idx: number, total: number) => {
|
||||||
|
|||||||
@@ -34,13 +34,12 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||||
const isDisabled = disabled || disabledByAdmin;
|
const isDisabled = disabled || disabledByAdmin;
|
||||||
const hasSubtitle = Boolean(effectiveSubtitle);
|
|
||||||
if (onPress)
|
if (onPress)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...(viewProps as any)}
|
{...(viewProps as any)}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
@@ -59,7 +58,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={`flex flex-row items-center justify-between bg-neutral-900 ${hasSubtitle ? "min-h-[48px] py-2" : "h-[48px]"} px-4 ${isDisabled ? "opacity-50" : ""}`}
|
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||||
{...viewProps}
|
{...viewProps}
|
||||||
>
|
>
|
||||||
<ListItemContent
|
<ListItemContent
|
||||||
|
|||||||
@@ -180,4 +180,4 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { CARD_HEIGHT, CARD_WIDTH };
|
export { CARD_WIDTH, CARD_HEIGHT };
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const TVLiveTVGuide: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Fetch programs for visible channels
|
// Fetch programs for visible channels
|
||||||
const { data: programsData } = useQuery({
|
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"livetv",
|
"livetv",
|
||||||
"tv-guide",
|
"tv-guide",
|
||||||
|
|||||||
@@ -382,18 +382,16 @@ export const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
<View className='flex flex-col flex-1 w-full'>
|
||||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
<View className='flex flex-col gap-y-2 px-4 w-full'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
marginBottom: -20,
|
||||||
|
alignSelf: "center",
|
||||||
}}
|
}}
|
||||||
source={require("@/assets/images/icon-ios-plain.png")}
|
source={require("@/assets/images/icon-ios-plain.png")}
|
||||||
/>
|
/>
|
||||||
@@ -431,6 +429,8 @@ export const Login: React.FC = () => {
|
|||||||
await handleConnect(server.address);
|
await handleConnect(server.address);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='px-4 pb-2'>
|
||||||
<PreviousServersList
|
<PreviousServersList
|
||||||
onServerSelect={async (s) => {
|
onServerSelect={async (s) => {
|
||||||
await handleConnect(s.address);
|
await handleConnect(s.address);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import React, { useCallback, useState } from "react";
|
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 { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Pair with Phone */}
|
{/* Pair with Phone */}
|
||||||
{Platform.OS !== "ios" && onStartPairing && (
|
{onStartPairing && (
|
||||||
<View>
|
<View>
|
||||||
<Button
|
<Button
|
||||||
onPress={onStartPairing}
|
onPress={onStartPairing}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv";
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
interface TVPasswordEntryModalProps {
|
interface TVPasswordEntryModalProps {
|
||||||
@@ -202,13 +201,6 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
// Close the modal on the TV remote back/menu button while it is open.
|
|
||||||
useTVBackPress(() => {
|
|
||||||
if (!visible) return false;
|
|
||||||
onClose();
|
|
||||||
return true;
|
|
||||||
}, [visible, onClose]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setError(t("password.enter_password"));
|
setError(t("password.enter_password"));
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ContextMenu,
|
||||||
|
Host,
|
||||||
|
Picker,
|
||||||
|
Text as SwiftUIText,
|
||||||
|
} from "@expo/ui/swift-ui";
|
||||||
|
import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
|
|
||||||
// @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, Menu } = 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");
|
|
||||||
|
|
||||||
interface DiscoverFiltersProps {
|
interface DiscoverFiltersProps {
|
||||||
searchFilterId: string;
|
searchFilterId: string;
|
||||||
orderFilterId: string;
|
orderFilterId: string;
|
||||||
@@ -37,7 +35,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
setJellyseerrSortOrder,
|
setJellyseerrSortOrder,
|
||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Host
|
<Host
|
||||||
style={{
|
style={{
|
||||||
@@ -49,54 +47,42 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu
|
<ContextMenu>
|
||||||
label={
|
<ContextMenu.Trigger>
|
||||||
<Button
|
<Button
|
||||||
modifiers={[buttonStyle("glass")]}
|
modifiers={[buttonStyle("glass")]}
|
||||||
systemImage='line.3.horizontal.decrease.circle'
|
systemImage='line.3.horizontal.decrease.circle'
|
||||||
/>
|
></Button>
|
||||||
}
|
</ContextMenu.Trigger>
|
||||||
>
|
<ContextMenu.Items>
|
||||||
<Menu
|
<Picker
|
||||||
label={`${t("library.filters.sort_by")}: ${t(
|
label={t("library.filters.sort_by")}
|
||||||
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
|
selection={jellyseerrOrderBy as unknown as string}
|
||||||
)}`}
|
onSelectionChange={(value) => {
|
||||||
>
|
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
|
||||||
{sortOptions.map((item) => {
|
}}
|
||||||
const isSelected =
|
>
|
||||||
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
|
{sortOptions.map((item) => (
|
||||||
return (
|
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
||||||
<Button
|
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
||||||
key={item}
|
</SwiftUIText>
|
||||||
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
))}
|
||||||
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
</Picker>
|
||||||
onPress={() =>
|
<Picker
|
||||||
setJellyseerrOrderBy(
|
label={t("library.filters.sort_order")}
|
||||||
item as unknown as JellyseerrSearchSort,
|
selection={jellyseerrSortOrder}
|
||||||
)
|
onSelectionChange={(value) => {
|
||||||
}
|
setJellyseerrSortOrder(value as "asc" | "desc");
|
||||||
/>
|
}}
|
||||||
);
|
>
|
||||||
})}
|
{orderOptions.map((item) => (
|
||||||
</Menu>
|
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
||||||
<Menu
|
{t(`library.filters.${item}`)}
|
||||||
label={`${t("library.filters.sort_order")}: ${t(
|
</SwiftUIText>
|
||||||
`library.filters.${jellyseerrSortOrder}`,
|
))}
|
||||||
)}`}
|
</Picker>
|
||||||
>
|
</ContextMenu.Items>
|
||||||
{orderOptions.map((item) => {
|
</ContextMenu>
|
||||||
const isSelected = jellyseerrSortOrder === item;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={item}
|
|
||||||
label={t(`library.filters.${item}`)}
|
|
||||||
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
|
||||||
onPress={() => setJellyseerrSortOrder(item)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
</Menu>
|
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
|
import { Button, Host } from "@expo/ui/swift-ui";
|
||||||
|
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
import { Tag } from "@/components/GenreTags";
|
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 SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
interface SearchTabButtonsProps {
|
interface SearchTabButtonsProps {
|
||||||
@@ -24,10 +17,18 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
|||||||
setSearchType,
|
setSearchType,
|
||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<Host style={{ height: 40, flex: 1 }}>
|
<>
|
||||||
<HStack spacing={8}>
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 80,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
modifiers={[
|
modifiers={[
|
||||||
buttonStyle(
|
buttonStyle(
|
||||||
@@ -35,8 +36,19 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
|||||||
),
|
),
|
||||||
]}
|
]}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
label={t("search.library")}
|
>
|
||||||
/>
|
<Text>{t("search.library")}</Text>
|
||||||
|
</Button>
|
||||||
|
</Host>
|
||||||
|
<Host
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
width: 100,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
modifiers={[
|
modifiers={[
|
||||||
buttonStyle(
|
buttonStyle(
|
||||||
@@ -44,11 +56,11 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
|||||||
),
|
),
|
||||||
]}
|
]}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
label={t("search.discover")}
|
>
|
||||||
/>
|
<Text>{t("search.discover")}</Text>
|
||||||
<Spacer />
|
</Button>
|
||||||
</HStack>
|
</Host>
|
||||||
</Host>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const hasMovies = movieResults && movieResults.length > 0;
|
||||||
|
const hasTv = tvResults && tvResults.length > 0;
|
||||||
|
const hasPersons = personResults && personResults.length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<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
|
<TVJellyseerrMovieSection
|
||||||
title={t("search.request_movies")}
|
title={t("search.request_movies")}
|
||||||
items={movieResults}
|
items={movieResults}
|
||||||
isFirstSection={false}
|
isFirstSection={hasMovies}
|
||||||
onItemPress={onMoviePress}
|
onItemPress={onMoviePress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrTvSection
|
<TVJellyseerrTvSection
|
||||||
title={t("search.request_series")}
|
title={t("search.request_series")}
|
||||||
items={tvResults}
|
items={tvResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && hasTv}
|
||||||
onItemPress={onTvPress}
|
onItemPress={onTvPress}
|
||||||
/>
|
/>
|
||||||
<TVJellyseerrPersonSection
|
<TVJellyseerrPersonSection
|
||||||
title={t("search.actors")}
|
title={t("search.actors")}
|
||||||
items={personResults}
|
items={personResults}
|
||||||
isFirstSection={false}
|
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||||
onItemPress={onPersonPress}
|
onItemPress={onPersonPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { TvSearchView } from "@/modules/tv-search";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
@@ -17,46 +16,36 @@ import type {
|
|||||||
PersonResult,
|
PersonResult,
|
||||||
TvResult,
|
TvResult,
|
||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
|
||||||
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
||||||
import { TVSearchSection } from "./TVSearchSection";
|
import { TVSearchSection } from "./TVSearchSection";
|
||||||
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
|
||||||
// its own overlay when the field is focused, so we only reserve the bar height
|
|
||||||
// here — not the whole keyboard. Tunable once seen on device.
|
|
||||||
const SEARCH_AREA_HEIGHT = 250;
|
|
||||||
const SECTION_GAP = 10;
|
const SECTION_GAP = 10;
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
// Loading skeleton for TV.
|
// Loading skeleton for TV
|
||||||
// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
|
|
||||||
// padding, heading typography, poster radius) so the placeholder lines up with
|
|
||||||
// the real content that replaces it.
|
|
||||||
const TVLoadingSkeleton: React.FC = () => {
|
const TVLoadingSkeleton: React.FC = () => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const sizes = useScaledTVSizes();
|
const itemWidth = 210;
|
||||||
const itemWidth = sizes.posters.poster;
|
|
||||||
return (
|
return (
|
||||||
<View style={{ overflow: "visible" }}>
|
<View style={{ overflow: "visible" }}>
|
||||||
{/* Section header placeholder — matches the heading typography + margins */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: itemWidth,
|
width: 200,
|
||||||
height: typography.heading,
|
height: 28,
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
marginLeft: sizes.padding.horizontal,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
gap: sizes.gaps.item,
|
gap: 16,
|
||||||
paddingLeft: sizes.padding.horizontal,
|
paddingHorizontal: SCALE_PADDING,
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -67,14 +56,15 @@ const TVLoadingSkeleton: React.FC = () => {
|
|||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
width: itemWidth,
|
width: itemWidth,
|
||||||
aspectRatio: 10 / 15,
|
aspectRatio: 10 / 15,
|
||||||
borderRadius: scaleSize(24),
|
borderRadius: 12,
|
||||||
marginBottom: scaleSize(8),
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
marginBottom: 4,
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -134,6 +124,7 @@ interface TVSearchPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||||
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
debouncedSearch,
|
debouncedSearch,
|
||||||
movies,
|
movies,
|
||||||
@@ -224,136 +215,125 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<ScrollView
|
||||||
{/* Sticky header: search field stays pinned while results scroll below. */}
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardDismissMode='on-drag'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search Input */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
marginBottom: 24,
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING + 200,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
<Input
|
||||||
module). It renders the native search bar + grid keyboard and
|
placeholder={t("search.search")}
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
value={search}
|
||||||
our own results grid renders below. */}
|
onChangeText={setSearch}
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
keyboardType='default'
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
returnKeyType='done'
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
autoCapitalize='none'
|
||||||
the native view span the full width and own its own insets. */}
|
clearButtonMode='while-editing'
|
||||||
<View
|
maxLength={500}
|
||||||
style={{
|
hasTVPreferredFocus={
|
||||||
marginBottom: 24,
|
debouncedSearch.length === 0 &&
|
||||||
height: SEARCH_AREA_HEIGHT,
|
sections.length === 0 &&
|
||||||
}}
|
!showDiscover
|
||||||
>
|
}
|
||||||
<TvSearchView
|
/>
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
{/* Search Type Tab Badges */}
|
||||||
nestedScrollEnabled
|
{showDiscover && (
|
||||||
showsVerticalScrollIndicator={false}
|
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||||
keyboardDismissMode='on-drag'
|
<TVSearchTabBadges
|
||||||
contentContainerStyle={{
|
searchType={searchType}
|
||||||
paddingBottom: insets.bottom + 60,
|
setSearchType={setSearchType}
|
||||||
}}
|
showDiscover={showDiscover}
|
||||||
>
|
|
||||||
{/* Search Type Tab Badges */}
|
|
||||||
{showDiscover && (
|
|
||||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
|
||||||
<TVSearchTabBadges
|
|
||||||
searchType={searchType}
|
|
||||||
setSearchType={setSearchType}
|
|
||||||
showDiscover={showDiscover}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{currentLoading && (
|
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
|
||||||
<TVLoadingSkeleton />
|
|
||||||
<TVLoadingSkeleton />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Library Search Results */}
|
|
||||||
{isLibraryMode && !loading && (
|
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
|
||||||
{sections.map((section) => (
|
|
||||||
<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}
|
|
||||||
onItemPress={onItemPress}
|
|
||||||
onItemLongPress={onItemLongPress}
|
|
||||||
imageUrlGetter={
|
|
||||||
["artists", "albums", "songs", "playlists"].includes(
|
|
||||||
section.key,
|
|
||||||
)
|
|
||||||
? getImageUrl
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Jellyseerr/Discover Search Results */}
|
|
||||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
|
||||||
<TVJellyseerrSearchResults
|
|
||||||
movieResults={jellyseerrMovies}
|
|
||||||
tvResults={jellyseerrTv}
|
|
||||||
personResults={jellyseerrPersons}
|
|
||||||
loading={jellyseerrLoading}
|
|
||||||
noResults={jellyseerrNoResults}
|
|
||||||
searchQuery={debouncedSearch}
|
|
||||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
|
||||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
|
||||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Discover Content (when no search query in Discover mode) */}
|
{/* Loading State */}
|
||||||
{isDiscoverMode &&
|
{currentLoading && (
|
||||||
!jellyseerrLoading &&
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
debouncedSearch.length === 0 && (
|
<TVLoadingSkeleton />
|
||||||
<TVDiscover sliders={discoverSliders} />
|
<TVLoadingSkeleton />
|
||||||
)}
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No Results State */}
|
{/* Library Search Results */}
|
||||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
<Text
|
{sections.map((section, index) => (
|
||||||
style={{
|
<TVSearchSection
|
||||||
fontSize: typography.heading,
|
key={section.key}
|
||||||
fontWeight: "bold",
|
title={section.title}
|
||||||
color: "#FFFFFF",
|
items={section.items!}
|
||||||
marginBottom: 8,
|
orientation={section.orientation || "vertical"}
|
||||||
}}
|
isFirstSection={index === 0}
|
||||||
>
|
onItemPress={onItemPress}
|
||||||
{t("search.no_results_found_for")}
|
onItemLongPress={onItemLongPress}
|
||||||
</Text>
|
imageUrlGetter={
|
||||||
<Text
|
["artists", "albums", "songs", "playlists"].includes(
|
||||||
style={{
|
section.key,
|
||||||
fontSize: typography.body,
|
)
|
||||||
color: "rgba(255,255,255,0.6)",
|
? getImageUrl
|
||||||
}}
|
: undefined
|
||||||
>
|
}
|
||||||
"{debouncedSearch}"
|
/>
|
||||||
</Text>
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
|
||||||
</View>
|
{/* Jellyseerr/Discover Search Results */}
|
||||||
|
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||||
|
<TVJellyseerrSearchResults
|
||||||
|
movieResults={jellyseerrMovies}
|
||||||
|
tvResults={jellyseerrTv}
|
||||||
|
personResults={jellyseerrPersons}
|
||||||
|
loading={jellyseerrLoading}
|
||||||
|
noResults={jellyseerrNoResults}
|
||||||
|
searchQuery={debouncedSearch}
|
||||||
|
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||||
|
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||||
|
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discover Content (when no search query in Discover mode) */}
|
||||||
|
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
|
||||||
|
<TVDiscover sliders={discoverSliders} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Results State */}
|
||||||
|
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||||
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("search.no_results_found_for")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
"{debouncedSearch}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ interface TVSearchSectionProps extends ViewProps {
|
|||||||
onItemPress: (item: BaseItemDto) => void;
|
onItemPress: (item: BaseItemDto) => void;
|
||||||
onItemLongPress?: (item: BaseItemDto) => void;
|
onItemLongPress?: (item: BaseItemDto) => void;
|
||||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
/** Override the horizontal edge padding (defaults to the scaled TV padding). */
|
|
||||||
horizontalPadding?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||||
@@ -33,14 +31,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
onItemPress,
|
onItemPress,
|
||||||
onItemLongPress,
|
onItemLongPress,
|
||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
horizontalPadding,
|
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const posterSizes = useScaledTVPosterSizes();
|
const posterSizes = useScaledTVPosterSizes();
|
||||||
const sizes = useScaledTVSizes();
|
const sizes = useScaledTVSizes();
|
||||||
const ITEM_GAP = sizes.gaps.item;
|
const ITEM_GAP = sizes.gaps.item;
|
||||||
const edgePadding = horizontalPadding ?? sizes.padding.horizontal;
|
|
||||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
const [focusedCount, setFocusedCount] = useState(0);
|
const [focusedCount, setFocusedCount] = useState(0);
|
||||||
const prevFocusedCount = useRef(0);
|
const prevFocusedCount = useRef(0);
|
||||||
@@ -277,7 +273,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
marginLeft: edgePadding,
|
marginLeft: sizes.padding.horizontal,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -297,12 +293,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
getItemLayout={getItemLayout}
|
getItemLayout={getItemLayout}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
contentInset={{
|
||||||
// contentOffset only applies on initial mount; since this FlatList is
|
left: sizes.padding.horizontal,
|
||||||
// reused across searches (stable key), a second search left the inset
|
right: sizes.padding.horizontal,
|
||||||
// without the offset and the grid snapped flush to the left edge.
|
}}
|
||||||
|
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: edgePadding,
|
|
||||||
paddingVertical: SCALE_PADDING,
|
paddingVertical: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,12 +31,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const router = useRouter();
|
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
// Read the live (cached) downloads DB inside the query rather than the
|
const router = useRouter();
|
||||||
// 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 { getDownloadedItems } = useDownload();
|
const { getDownloadedItems } = useDownload();
|
||||||
|
|
||||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||||
@@ -104,7 +100,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.setParams({ id: _item.Id });
|
router.setParams({ id: _item.Id });
|
||||||
}}
|
}}
|
||||||
className={`flex flex-col w-44
|
className={`flex flex-col w-44
|
||||||
${item?.Id === _item.Id ? "" : "opacity-50"}
|
${item?.Id === _item.Id ? "" : "opacity-50"}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ const TVSeasonButton: React.FC<{
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.body,
|
||||||
color: focused ? "#000" : "#FFFFFF",
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
@@ -538,7 +538,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: typography.body,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { Switch, View } from "react-native";
|
||||||
import { View } from "react-native";
|
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={t("home.settings.chromecast.title")}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<SettingsSwitchRow
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
title={t("home.settings.chromecast.enable_h265")}
|
<Switch
|
||||||
value={settings.enableH265ForChromecast}
|
value={settings.enableH265ForChromecast}
|
||||||
onValueChange={(enableH265ForChromecast) =>
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
updateSettings({ enableH265ForChromecast })
|
updateSettings({ enableH265ForChromecast })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Switch } from "react-native";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -31,65 +32,85 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
|||||||
<ListGroup
|
<ListGroup
|
||||||
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
title={t("home.settings.gesture_controls.gesture_controls_title")}
|
||||||
>
|
>
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
title={t("home.settings.gesture_controls.horizontal_swipe_skip")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
"home.settings.gesture_controls.horizontal_swipe_skip_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
value={settings.enableHorizontalSwipeSkip}
|
>
|
||||||
onValueChange={(enableHorizontalSwipeSkip) =>
|
<Switch
|
||||||
updateSettings({ enableHorizontalSwipeSkip })
|
value={settings.enableHorizontalSwipeSkip}
|
||||||
}
|
disabled={pluginSettings?.enableHorizontalSwipeSkip?.locked}
|
||||||
/>
|
onValueChange={(enableHorizontalSwipeSkip) =>
|
||||||
|
updateSettings({ enableHorizontalSwipeSkip })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.left_side_brightness")}
|
title={t("home.settings.gesture_controls.left_side_brightness")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.left_side_brightness_description",
|
"home.settings.gesture_controls.left_side_brightness_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
value={settings.enableLeftSideBrightnessSwipe}
|
>
|
||||||
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
<Switch
|
||||||
updateSettings({ enableLeftSideBrightnessSwipe })
|
value={settings.enableLeftSideBrightnessSwipe}
|
||||||
}
|
disabled={pluginSettings?.enableLeftSideBrightnessSwipe?.locked}
|
||||||
/>
|
onValueChange={(enableLeftSideBrightnessSwipe) =>
|
||||||
|
updateSettings({ enableLeftSideBrightnessSwipe })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.right_side_volume")}
|
title={t("home.settings.gesture_controls.right_side_volume")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.right_side_volume_description",
|
"home.settings.gesture_controls.right_side_volume_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
value={settings.enableRightSideVolumeSwipe}
|
>
|
||||||
onValueChange={(enableRightSideVolumeSwipe) =>
|
<Switch
|
||||||
updateSettings({ enableRightSideVolumeSwipe })
|
value={settings.enableRightSideVolumeSwipe}
|
||||||
}
|
disabled={pluginSettings?.enableRightSideVolumeSwipe?.locked}
|
||||||
/>
|
onValueChange={(enableRightSideVolumeSwipe) =>
|
||||||
|
updateSettings({ enableRightSideVolumeSwipe })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_volume_slider_description",
|
"home.settings.gesture_controls.hide_volume_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
value={settings.hideVolumeSlider}
|
>
|
||||||
onValueChange={(hideVolumeSlider) =>
|
<Switch
|
||||||
updateSettings({ hideVolumeSlider })
|
value={settings.hideVolumeSlider}
|
||||||
}
|
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||||
/>
|
onValueChange={(hideVolumeSlider) =>
|
||||||
|
updateSettings({ hideVolumeSlider })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
||||||
subtitle={t(
|
subtitle={t(
|
||||||
"home.settings.gesture_controls.hide_brightness_slider_description",
|
"home.settings.gesture_controls.hide_brightness_slider_description",
|
||||||
)}
|
)}
|
||||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
value={settings.hideBrightnessSlider}
|
>
|
||||||
onValueChange={(hideBrightnessSlider) =>
|
<Switch
|
||||||
updateSettings({ hideBrightnessSlider })
|
value={settings.hideBrightnessSlider}
|
||||||
}
|
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||||
/>
|
onValueChange={(hideBrightnessSlider) =>
|
||||||
|
updateSettings({ hideBrightnessSlider })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</OptionGroup>
|
</OptionGroup>
|
||||||
|
|
||||||
<OptionGroup title={t("library.options.options_title")}>
|
<OptionGroup title='Options'>
|
||||||
<ToggleItem
|
<ToggleItem
|
||||||
label={t("library.options.show_titles")}
|
label={t("library.options.show_titles")}
|
||||||
value={settings.showTitles}
|
value={settings.showTitles}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import type React from "react";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewProps } from "react-native";
|
import type { ViewProps } from "react-native";
|
||||||
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -26,27 +27,35 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled} {...props}>
|
<DisabledSetting disabled={disabled} {...props}>
|
||||||
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
<ListGroup title={t("home.settings.media_controls.media_controls_title")}>
|
||||||
<SettingsStepperRow
|
<ListItem
|
||||||
title={t("home.settings.media_controls.forward_skip_length")}
|
title={t("home.settings.media_controls.forward_skip_length")}
|
||||||
disabled={pluginSettings?.forwardSkipTime?.locked}
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
value={settings.forwardSkipTime}
|
>
|
||||||
step={5}
|
<Stepper
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
value={settings.forwardSkipTime}
|
||||||
min={0}
|
disabled={pluginSettings?.forwardSkipTime?.locked}
|
||||||
max={60}
|
step={5}
|
||||||
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
/>
|
min={0}
|
||||||
|
max={60}
|
||||||
|
onUpdate={(forwardSkipTime) => updateSettings({ forwardSkipTime })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem
|
||||||
title={t("home.settings.media_controls.rewind_length")}
|
title={t("home.settings.media_controls.rewind_length")}
|
||||||
disabled={pluginSettings?.rewindSkipTime?.locked}
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
value={settings.rewindSkipTime}
|
>
|
||||||
step={5}
|
<Stepper
|
||||||
appendValue={t("home.settings.media_controls.seconds_unit")}
|
value={settings.rewindSkipTime}
|
||||||
min={0}
|
disabled={pluginSettings?.rewindSkipTime?.locked}
|
||||||
max={60}
|
step={5}
|
||||||
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
appendValue={t("home.settings.media_controls.seconds_unit")}
|
||||||
/>
|
min={0}
|
||||||
|
max={60}
|
||||||
|
onUpdate={(rewindSkipTime) => updateSettings({ rewindSkipTime })}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
import { View } from "react-native";
|
||||||
import { SettingsStepperRow } from "@/components/settings/index/SettingsStepperRow";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
import { type MpvCacheMode, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
const CACHE_MODE_OPTIONS: { key: string; value: MpvCacheMode }[] = [
|
||||||
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
{ key: "home.settings.buffer.cache_auto", value: "auto" },
|
||||||
@@ -41,43 +45,56 @@ export const MpvBufferSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.buffer.title")}>
|
<ListGroup title={t("home.settings.buffer.title")} className='mb-4'>
|
||||||
<SettingsSelectRow
|
<ListItem title={t("home.settings.buffer.cache_mode")}>
|
||||||
title={t("home.settings.buffer.cache_mode")}
|
<PlatformDropdown
|
||||||
valueLabel={currentCacheModeLabel}
|
groups={cacheModeOptions}
|
||||||
groups={cacheModeOptions}
|
trigger={
|
||||||
dropdownTitle={t("home.settings.buffer.cache_mode")}
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
/>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentCacheModeLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.buffer.cache_mode")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.buffer_duration")}>
|
||||||
title={t("home.settings.buffer.buffer_duration")}
|
<Stepper
|
||||||
value={settings.mpvCacheSeconds ?? 10}
|
value={settings.mpvCacheSeconds ?? 10}
|
||||||
step={5}
|
step={5}
|
||||||
min={5}
|
min={5}
|
||||||
max={120}
|
max={120}
|
||||||
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
onUpdate={(value) => updateSettings({ mpvCacheSeconds: value })}
|
||||||
appendValue='s'
|
appendValue='s'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.max_cache_size")}>
|
||||||
title={t("home.settings.buffer.max_cache_size")}
|
<Stepper
|
||||||
value={settings.mpvDemuxerMaxBytes ?? 150}
|
value={settings.mpvDemuxerMaxBytes ?? 150}
|
||||||
step={25}
|
step={25}
|
||||||
min={50}
|
min={50}
|
||||||
max={500}
|
max={500}
|
||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBytes: value })}
|
||||||
appendValue=' MB'
|
appendValue=' MB'
|
||||||
/>
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsStepperRow
|
<ListItem title={t("home.settings.buffer.max_backward_cache")}>
|
||||||
title={t("home.settings.buffer.max_backward_cache")}
|
<Stepper
|
||||||
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
value={settings.mpvDemuxerMaxBackBytes ?? 50}
|
||||||
step={25}
|
step={25}
|
||||||
min={25}
|
min={25}
|
||||||
max={200}
|
max={200}
|
||||||
onUpdate={(value) => updateSettings({ mpvDemuxerMaxBackBytes: value })}
|
onUpdate={(value) =>
|
||||||
appendValue=' MB'
|
updateSettings({ mpvDemuxerMaxBackBytes: value })
|
||||||
/>
|
}
|
||||||
|
appendValue=' MB'
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
import { type MpvVoDriver, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
const VO_DRIVER_OPTIONS: { key: string; value: MpvVoDriver }[] = [
|
||||||
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
{ key: "home.settings.vo_driver.gpu_next", value: "gpu-next" },
|
||||||
@@ -43,13 +46,21 @@ export const MpvVoSettings: React.FC = () => {
|
|||||||
if (!settings) return null;
|
if (!settings) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListGroup title={t("home.settings.vo_driver.title")}>
|
<ListGroup title={t("home.settings.vo_driver.title")} className='mb-4'>
|
||||||
<SettingsSelectRow
|
<ListItem title={t("home.settings.vo_driver.vo_mode")}>
|
||||||
title={t("home.settings.vo_driver.vo_mode")}
|
<PlatformDropdown
|
||||||
valueLabel={currentVoDriverLabel}
|
groups={voDriverOptions}
|
||||||
groups={voDriverOptions}
|
trigger={
|
||||||
dropdownTitle={t("home.settings.vo_driver.vo_mode")}
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
/>
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{currentVoDriverLabel}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.vo_driver.vo_mode")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -196,10 +196,7 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem
|
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
|
||||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
trigger={
|
trigger={
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
import { BITRATES } from "@/components/BitrateSelector";
|
||||||
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
import { SettingsSelectRow } from "@/components/settings/index/SettingsSelectRow";
|
|
||||||
import { SettingsSwitchRow } from "@/components/settings/index/SettingsSwitchRow";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { Text } from "../common/Text";
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
@@ -113,77 +116,138 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<DisabledSetting disabled={disabled}>
|
<DisabledSetting disabled={disabled}>
|
||||||
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
<ListGroup title={t("home.settings.other.other_title")} className=''>
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.video_orientation")}
|
title={t("home.settings.other.video_orientation")}
|
||||||
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
disabled={pluginSettings?.defaultVideoOrientation?.locked}
|
||||||
valueLabel={
|
>
|
||||||
t(
|
<PlatformDropdown
|
||||||
orientationTranslations[
|
groups={orientationOptions}
|
||||||
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
trigger={
|
||||||
],
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
) || "Unknown Orientation"
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
}
|
{t(
|
||||||
groups={orientationOptions}
|
orientationTranslations[
|
||||||
dropdownTitle={t("home.settings.other.orientation")}
|
settings.defaultVideoOrientation as keyof typeof orientationTranslations
|
||||||
/>
|
],
|
||||||
|
) || "Unknown Orientation"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.orientation")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.safe_area_in_controls")}
|
title={t("home.settings.other.safe_area_in_controls")}
|
||||||
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
value={settings.safeAreaInControlsEnabled}
|
>
|
||||||
onValueChange={(value) =>
|
<Switch
|
||||||
updateSettings({ safeAreaInControlsEnabled: value })
|
value={settings.safeAreaInControlsEnabled}
|
||||||
}
|
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
|
||||||
/>
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ safeAreaInControlsEnabled: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.default_quality")}
|
title={t("home.settings.other.default_quality")}
|
||||||
disabled={pluginSettings?.defaultBitrate?.locked}
|
disabled={pluginSettings?.defaultBitrate?.locked}
|
||||||
valueLabel={settings.defaultBitrate?.key}
|
>
|
||||||
groups={bitrateOptions}
|
<PlatformDropdown
|
||||||
dropdownTitle={t("home.settings.other.default_quality")}
|
groups={bitrateOptions}
|
||||||
/>
|
trigger={
|
||||||
|
<View className='flex flex-row items-center justify-between pl-3 py-1.5 '>
|
||||||
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
|
{settings.defaultBitrate?.key}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.default_quality")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.default_playback_speed")}
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
disabled={pluginSettings?.defaultPlaybackSpeed?.locked}
|
||||||
valueLabel={
|
>
|
||||||
PLAYBACK_SPEEDS.find(
|
<PlatformDropdown
|
||||||
(s) => s.value === settings.defaultPlaybackSpeed,
|
groups={playbackSpeedOptions}
|
||||||
)?.label ?? "1x"
|
trigger={
|
||||||
}
|
<View className='flex flex-row items-center justify-between pl-3 py-1.5'>
|
||||||
groups={playbackSpeedOptions}
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
dropdownTitle={t("home.settings.other.default_playback_speed")}
|
{PLAYBACK_SPEEDS.find(
|
||||||
/>
|
(s) => s.value === settings.defaultPlaybackSpeed,
|
||||||
|
)?.label ?? "1x"}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.default_playback_speed")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.disable_haptic_feedback")}
|
title={t("home.settings.other.disable_haptic_feedback")}
|
||||||
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
value={settings.disableHapticFeedback}
|
>
|
||||||
onValueChange={(disableHapticFeedback) =>
|
<Switch
|
||||||
updateSettings({ disableHapticFeedback })
|
value={settings.disableHapticFeedback}
|
||||||
}
|
disabled={pluginSettings?.disableHapticFeedback?.locked}
|
||||||
/>
|
onValueChange={(disableHapticFeedback) =>
|
||||||
|
updateSettings({ disableHapticFeedback })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSwitchRow
|
<ListItem
|
||||||
title={t("home.settings.other.auto_play_next_episode")}
|
title={t("home.settings.other.auto_play_next_episode")}
|
||||||
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
value={settings.autoPlayNextEpisode}
|
>
|
||||||
onValueChange={(autoPlayNextEpisode) =>
|
<Switch
|
||||||
updateSettings({ autoPlayNextEpisode })
|
value={settings.autoPlayNextEpisode}
|
||||||
}
|
disabled={pluginSettings?.autoPlayNextEpisode?.locked}
|
||||||
/>
|
onValueChange={(autoPlayNextEpisode) =>
|
||||||
|
updateSettings({ autoPlayNextEpisode })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
<SettingsSelectRow
|
<ListItem
|
||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
disabled={
|
disabled={!settings.autoPlayNextEpisode}
|
||||||
!settings.autoPlayNextEpisode ||
|
>
|
||||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
<PlatformDropdown
|
||||||
}
|
groups={autoPlayEpisodeOptions}
|
||||||
valueLabel={t(settings?.maxAutoPlayEpisodeCount.key)}
|
trigger={
|
||||||
groups={autoPlayEpisodeOptions}
|
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||||
dropdownTitle={t("home.settings.other.max_auto_play_episode_count")}
|
<Text className='mr-1 text-[#8E8D91]'>
|
||||||
/>
|
{t(settings?.maxAutoPlayEpisodeCount.key)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-expand-sharp'
|
||||||
|
size={18}
|
||||||
|
color='#5A5960'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,57 +1,54 @@
|
|||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
BottomSheetBackdrop,
|
||||||
useCallback,
|
type BottomSheetBackdropProps,
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Alert, Platform, View } from "react-native";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
type BottomSheetMethods,
|
|
||||||
BottomSheetModal,
|
BottomSheetModal,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
} from "@/utils/expoUiBottomSheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { PinInput } from "../inputs/PinInput";
|
import { PinInput } from "../inputs/PinInput";
|
||||||
|
import { ListGroup } from "../list/ListGroup";
|
||||||
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export type QuickConnectSheetRef = { present: () => void };
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
||||||
(_props, ref) => {
|
const isTv = Platform.isTV;
|
||||||
const isTv = Platform.isTV;
|
const [api] = useAtom(apiAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
||||||
const [quickConnectCode, setQuickConnectCode] = useState<string>();
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
const modalRef = useRef<BottomSheetMethods>(null);
|
const successHapticFeedback = useHaptic("success");
|
||||||
const successHapticFeedback = useHaptic("success");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const snapPoints = useMemo(
|
||||||
const snapPoints = useMemo(
|
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
||||||
() => (Platform.OS === "android" ? ["100%"] : ["40%"]),
|
[],
|
||||||
[],
|
);
|
||||||
);
|
const isAndroid = Platform.OS === "android";
|
||||||
const isAndroid = Platform.OS === "android";
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
useImperativeHandle(
|
const { t } = useTranslation();
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
present: () => {
|
|
||||||
setQuickConnectCode("");
|
|
||||||
modalRef.current?.present();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const authorizeQuickConnect = useCallback(async () => {
|
const renderBackdrop = useCallback(
|
||||||
if (!quickConnectCode) return;
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorizeQuickConnect = useCallback(async () => {
|
||||||
|
if (quickConnectCode) {
|
||||||
try {
|
try {
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
||||||
code: quickConnectCode,
|
code: quickConnectCode,
|
||||||
@@ -64,7 +61,7 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_autorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
modalRef.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
} else {
|
} else {
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -79,26 +76,39 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
t("home.settings.quick_connect.invalid_code"),
|
t("home.settings.quick_connect.invalid_code"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
}
|
||||||
api,
|
}, [api, user, quickConnectCode]);
|
||||||
user,
|
|
||||||
quickConnectCode,
|
|
||||||
t,
|
|
||||||
successHapticFeedback,
|
|
||||||
errorHapticFeedback,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isTv) return null;
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<ListGroup title={t("home.settings.quick_connect.quick_connect_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
// Reset the code when opening the sheet
|
||||||
|
setQuickConnectCode("");
|
||||||
|
bottomSheetModalRef?.current?.present();
|
||||||
|
}}
|
||||||
|
title={t("home.settings.quick_connect.authorize_button")}
|
||||||
|
textColor='blue'
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
return (
|
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
ref={modalRef}
|
ref={bottomSheetModalRef}
|
||||||
enablePanDownToClose
|
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
handleIndicatorStyle={{
|
||||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
|
||||||
keyboardBlurBehavior='restore'
|
keyboardBlurBehavior='restore'
|
||||||
|
android_keyboardInputMode='adjustResize'
|
||||||
|
topInset={isAndroid ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<BottomSheetView>
|
<BottomSheetView>
|
||||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
@@ -132,8 +142,6 @@ export const QuickConnectSheet = forwardRef<QuickConnectSheetRef>(
|
|||||||
</View>
|
</View>
|
||||||
</BottomSheetView>
|
</BottomSheetView>
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
</View>
|
||||||
},
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
QuickConnectSheet.displayName = "QuickConnectSheet";
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import type React from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserImageUrl } from "@/utils/jellyfin/image/getUserImageUrl";
|
|
||||||
|
|
||||||
export const SettingsHero: React.FC<{ onPress: () => void }> = ({
|
|
||||||
onPress,
|
|
||||||
}) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const connected = Boolean(api && user);
|
|
||||||
const imageUrl =
|
|
||||||
api && user?.Id
|
|
||||||
? (getUserImageUrl({
|
|
||||||
serverAddress: api.basePath,
|
|
||||||
userId: user.Id,
|
|
||||||
primaryImageTag: user.PrimaryImageTag,
|
|
||||||
}) ?? undefined)
|
|
||||||
: undefined;
|
|
||||||
const host = api?.basePath?.replace(/^https?:\/\//, "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPress}
|
|
||||||
className='mx-3 mb-4 rounded-2xl overflow-hidden'
|
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#241b33", "#15151a"]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<View className='flex-row items-center p-4'>
|
|
||||||
{imageUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: 52, height: 52, borderRadius: 26 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LinearGradient
|
|
||||||
colors={["#a855f7", "#6d28d9"]}
|
|
||||||
style={{
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
borderRadius: 26,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-white text-[22px] font-bold'>
|
|
||||||
{(user?.Name?.[0] ?? "?").toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
)}
|
|
||||||
<View className='flex-1 ml-3'>
|
|
||||||
<Text
|
|
||||||
className='text-white text-[18px] font-bold'
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{user?.Name ?? ""}
|
|
||||||
</Text>
|
|
||||||
<View className='flex-row items-center mt-0.5'>
|
|
||||||
<View
|
|
||||||
className='w-2 h-2 rounded-full mr-1.5'
|
|
||||||
style={{ backgroundColor: connected ? "#30D158" : "#8E8D91" }}
|
|
||||||
/>
|
|
||||||
<Text className='text-[#9899A1] text-[13px]' numberOfLines={1}>
|
|
||||||
{host}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Ionicons name='chevron-forward' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
|
|
||||||
export interface SettingsRowProps {
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
value?: string;
|
|
||||||
showChevron?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
isLast?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCENT = "#a855f7"; // single accent (full theming is a separate sub-project)
|
|
||||||
|
|
||||||
export const SettingsRow: React.FC<SettingsRowProps> = ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
value,
|
|
||||||
showChevron = true,
|
|
||||||
onPress,
|
|
||||||
isLast = false,
|
|
||||||
}) => {
|
|
||||||
const haptic = useHaptic("light"); // no-op when disableHapticFeedback is set
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
haptic();
|
|
||||||
onPress();
|
|
||||||
}}
|
|
||||||
className={`flex flex-row items-center bg-neutral-900 h-[48px] px-3 ${
|
|
||||||
isLast ? "" : "border-b border-[#ffffff14]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
className='h-[29px] w-[29px] rounded-[7px] items-center justify-center mr-3'
|
|
||||||
style={{ backgroundColor: `${ACCENT}29` }}
|
|
||||||
>
|
|
||||||
<Ionicons name={icon} size={17} color='#c79bff' />
|
|
||||||
</View>
|
|
||||||
<Text className='flex-1 text-white text-[15px]' numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{value ? (
|
|
||||||
<Text className='text-[#9899A1] text-[15px] ml-2' numberOfLines={1}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{showChevron ? (
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-forward'
|
|
||||||
size={17}
|
|
||||||
color='#5A5960'
|
|
||||||
style={{ marginLeft: 4 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import type React from "react";
|
|
||||||
import { TextInput, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export const SettingsSearchBar: React.FC<{
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}> = ({ value, onChange }) => (
|
|
||||||
<View className='mx-3 mb-4 h-[38px] rounded-xl bg-neutral-800 flex-row items-center px-3'>
|
|
||||||
<Ionicons name='search' size={16} color='#76767c' />
|
|
||||||
<TextInput
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChange}
|
|
||||||
placeholder={t("home.settings.search_placeholder")}
|
|
||||||
placeholderTextColor='#76767c'
|
|
||||||
className='flex-1 ml-2 text-white text-[15px]'
|
|
||||||
autoCapitalize='none'
|
|
||||||
autoCorrect={false}
|
|
||||||
returnKeyType='search'
|
|
||||||
/>
|
|
||||||
{value.length > 0 ? (
|
|
||||||
<TouchableOpacity onPress={() => onChange("")} hitSlop={8}>
|
|
||||||
<Ionicons name='close-circle' size={18} color='#76767c' />
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
export const SettingsSection: React.FC<{
|
|
||||||
title?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ title, children }) => (
|
|
||||||
<View className='mb-5'>
|
|
||||||
{title ? (
|
|
||||||
<Text className='ml-4 mb-1.5 uppercase text-[#8E8D91] text-[11px] tracking-wide'>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<View className='mx-3 rounded-xl overflow-hidden bg-neutral-900'>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import {
|
|
||||||
type OptionGroup,
|
|
||||||
PlatformDropdown,
|
|
||||||
} from "@/components/PlatformDropdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
valueLabel?: string;
|
|
||||||
groups: OptionGroup[];
|
|
||||||
dropdownTitle?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsSelectRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
valueLabel,
|
|
||||||
groups,
|
|
||||||
dropdownTitle,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} disabled={disabled}>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={groups}
|
|
||||||
title={dropdownTitle}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{valueLabel}</Text>
|
|
||||||
<Ionicons name='chevron-expand-sharp' size={18} color='#5A5960' />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: number;
|
|
||||||
step: number;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
onUpdate: (value: number) => void;
|
|
||||||
appendValue?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsStepperRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
step,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
onUpdate,
|
|
||||||
appendValue,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
|
||||||
<Stepper
|
|
||||||
value={value}
|
|
||||||
step={step}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
onUpdate={onUpdate}
|
|
||||||
appendValue={appendValue}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type React from "react";
|
|
||||||
import { Switch } from "react-native";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
value: boolean;
|
|
||||||
onValueChange: (value: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsSwitchRow: React.FC<Props> = ({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
disabled,
|
|
||||||
}) => (
|
|
||||||
<ListItem title={title} subtitle={subtitle} disabled={disabled}>
|
|
||||||
<Switch value={value} disabled={disabled} onValueChange={onValueChange} />
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { expect, test } from "bun:test";
|
|
||||||
import { matchesQuery, normalize } from "./searchFilter";
|
|
||||||
|
|
||||||
test("normalize strips accents and lowercases", () => {
|
|
||||||
expect(normalize("Légèreté")).toBe("legerete");
|
|
||||||
expect(normalize(" AUDIO ")).toBe("audio");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesQuery matches title case/accent-insensitively", () => {
|
|
||||||
expect(matchesQuery({ title: "Apparence", keywords: [] }, "appar")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
matchesQuery({ title: "Audio", keywords: ["sous-titres"] }, "SOUS"),
|
|
||||||
).toBe(true);
|
|
||||||
expect(matchesQuery({ title: "Music", keywords: [] }, "xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matchesQuery returns true for empty query", () => {
|
|
||||||
expect(matchesQuery({ title: "Anything" }, "")).toBe(true);
|
|
||||||
expect(matchesQuery({ title: "Anything" }, " ")).toBe(true);
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export const normalize = (s: string): string =>
|
|
||||||
s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
|
|
||||||
|
|
||||||
export interface Searchable {
|
|
||||||
title: string;
|
|
||||||
keywords?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const matchesQuery = (item: Searchable, query: string): boolean => {
|
|
||||||
const q = normalize(query);
|
|
||||||
if (!q) return true;
|
|
||||||
const hay = normalize([item.title, ...(item.keywords ?? [])].join(" "));
|
|
||||||
return hay.includes(q);
|
|
||||||
};
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import type { Ionicons } from "@expo/vector-icons";
|
|
||||||
|
|
||||||
export type SettingsTarget =
|
|
||||||
| { type: "route"; route: string }
|
|
||||||
| { type: "action"; action: "quickConnect" };
|
|
||||||
|
|
||||||
export interface SettingsEntry {
|
|
||||||
id: string;
|
|
||||||
titleKey: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
target: SettingsTarget;
|
|
||||||
/** extra search terms (English); the title is always searched too */
|
|
||||||
keywords?: string[];
|
|
||||||
/** when set, entry only shows on these platforms */
|
|
||||||
platforms?: ("ios" | "android")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SettingsSectionDef {
|
|
||||||
id: string;
|
|
||||||
titleKey: string;
|
|
||||||
entries: SettingsEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single source of truth for the Settings index: drives both rendering and search.
|
|
||||||
*
|
|
||||||
* EXTENSIBLE SHELL — to add a setting that lands from an in-flight PR, append one
|
|
||||||
* entry to the right section (and, if it lives inside a sub-page, an entry in
|
|
||||||
* settingsSearchIndex.ts). No screen rewrite needed. Reserved slots (add when the
|
|
||||||
* PR merges): sleep timer #922, sync-play #1612, wake-on-LAN #1539 (advanced);
|
|
||||||
* download location #1486/#1193, clear image cache #1589 (storage/downloads);
|
|
||||||
* double-tap seek #1219/#1289, subtitle background #1543 (playback).
|
|
||||||
*/
|
|
||||||
export const SETTINGS_CATALOG: SettingsSectionDef[] = [
|
|
||||||
{
|
|
||||||
id: "playback",
|
|
||||||
titleKey: "home.settings.categories.playback",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "playback-controls",
|
|
||||||
titleKey: "home.settings.playback_controls.title",
|
|
||||||
icon: "play",
|
|
||||||
target: { type: "route", route: "/settings/playback-controls/page" },
|
|
||||||
keywords: ["speed", "skip", "autoplay", "orientation"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audio-subtitles",
|
|
||||||
titleKey: "home.settings.audio_subtitles.title",
|
|
||||||
icon: "chatbox-ellipses",
|
|
||||||
target: { type: "route", route: "/settings/audio-subtitles/page" },
|
|
||||||
keywords: ["subtitle", "audio", "language"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "music",
|
|
||||||
titleKey: "home.settings.music.title",
|
|
||||||
icon: "musical-notes",
|
|
||||||
target: { type: "route", route: "/settings/music/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "personalization",
|
|
||||||
titleKey: "home.settings.categories.personalization",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "appearance",
|
|
||||||
titleKey: "home.settings.appearance.title",
|
|
||||||
icon: "color-palette",
|
|
||||||
target: { type: "route", route: "/settings/appearance/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notifications",
|
|
||||||
titleKey: "home.settings.notifications.title",
|
|
||||||
icon: "notifications",
|
|
||||||
target: { type: "route", route: "/settings/notifications/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "advanced",
|
|
||||||
titleKey: "home.settings.categories.advanced",
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
id: "quick-connect",
|
|
||||||
titleKey: "home.settings.quick_connect.quick_connect_title",
|
|
||||||
icon: "key",
|
|
||||||
target: { type: "action", action: "quickConnect" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pair",
|
|
||||||
titleKey: "pairing.pair_with_phone",
|
|
||||||
icon: "phone-portrait",
|
|
||||||
target: {
|
|
||||||
type: "route",
|
|
||||||
route: "/(auth)/(tabs)/(home)/companion-login",
|
|
||||||
},
|
|
||||||
platforms: ["android"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "plugins",
|
|
||||||
titleKey: "home.settings.plugins.plugins_title",
|
|
||||||
icon: "extension-puzzle",
|
|
||||||
target: { type: "route", route: "/settings/plugins/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "network",
|
|
||||||
titleKey: "home.settings.network.title",
|
|
||||||
icon: "wifi",
|
|
||||||
target: { type: "route", route: "/settings/network/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "logs",
|
|
||||||
titleKey: "home.settings.logs.logs_title",
|
|
||||||
icon: "document-text",
|
|
||||||
target: { type: "route", route: "/settings/logs/page" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "intro",
|
|
||||||
titleKey: "home.settings.intro.title",
|
|
||||||
icon: "information-circle",
|
|
||||||
target: { type: "route", route: "/settings/intro/page" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
export interface SearchableOption {
|
|
||||||
titleKey: string;
|
|
||||||
parentRoute: string;
|
|
||||||
parentTitleKey: string;
|
|
||||||
keywords?: string[];
|
|
||||||
platforms?: ("ios" | "android")[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal options of sub-pages, for deep search ("index + internal settings").
|
|
||||||
* Populated from a per-sub-page audit of every user-facing ListItem/dropdown row.
|
|
||||||
* Every titleKey/parentTitleKey MUST be a real existing i18n key.
|
|
||||||
*/
|
|
||||||
export const SETTINGS_SEARCH_INDEX: SearchableOption[] = [
|
|
||||||
// --- Playback & Controls -------------------------------------------------
|
|
||||||
// MediaToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.media_controls.forward_skip_length",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["skip", "forward", "seconds"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.media_controls.rewind_length",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["rewind", "back", "seconds"],
|
|
||||||
},
|
|
||||||
// GestureControls
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.horizontal_swipe_skip",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "skip", "seek"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.left_side_brightness",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "brightness", "left"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.right_side_volume",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["gesture", "swipe", "volume", "right"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.hide_volume_slider",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["volume", "slider", "hide", "gesture"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.gesture_controls.hide_brightness_slider",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["brightness", "slider", "hide", "gesture"],
|
|
||||||
},
|
|
||||||
// PlaybackControlsSettings (home.settings.other.*)
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.video_orientation",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["orientation", "rotate", "landscape", "portrait"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.safe_area_in_controls",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["safe", "area", "notch", "controls", "inset"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.default_quality",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["quality", "bitrate", "resolution"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.default_playback_speed",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["playback", "speed", "rate"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.disable_haptic_feedback",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["haptic", "vibration", "feedback"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.auto_play_next_episode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["autoplay", "auto", "next", "episode"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.max_auto_play_episode_count",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["autoplay", "auto", "episode", "count", "limit"],
|
|
||||||
},
|
|
||||||
// MpvBufferSettings
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.cache_mode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["cache", "buffer", "mode"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.buffer_duration",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["buffer", "duration", "cache", "seconds"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.buffer.max_cache_size",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["buffer", "cache", "size", "memory"],
|
|
||||||
},
|
|
||||||
// MpvVoSettings (Android only)
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.vo_driver.vo_mode",
|
|
||||||
parentRoute: "/settings/playback-controls/page",
|
|
||||||
parentTitleKey: "home.settings.playback_controls.title",
|
|
||||||
keywords: ["video", "output", "driver", "gpu", "mpv"],
|
|
||||||
platforms: ["android"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Audio & Subtitles ---------------------------------------------------
|
|
||||||
// AudioToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.set_audio_track",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "track", "remember", "previous"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.audio_language",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "language", "default"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.audio.transcode_mode.title",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["audio", "transcode", "surround", "stereo", "passthrough"],
|
|
||||||
},
|
|
||||||
// SubtitleToggles
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_language",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "language", "default"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_mode",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "mode", "forced"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.set_subtitle_track",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "caption", "track", "remember", "previous"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.subtitles.subtitle_size",
|
|
||||||
parentRoute: "/settings/audio-subtitles/page",
|
|
||||||
parentTitleKey: "home.settings.audio_subtitles.title",
|
|
||||||
keywords: ["subtitle", "size", "caption", "scale", "font"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Music ---------------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.prefer_downloaded",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "downloaded", "offline", "local"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.lookahead_enabled",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "lookahead", "cache", "prefetch"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.lookahead_count",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "lookahead", "cache", "count", "tracks"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.music.max_cache_size",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "cache", "size", "storage"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.storage.clear_music_cache",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "cache", "clear", "storage"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.storage.delete_all_downloaded_songs",
|
|
||||||
parentRoute: "/settings/music/page",
|
|
||||||
parentTitleKey: "home.settings.music.title",
|
|
||||||
keywords: ["music", "downloaded", "delete", "songs", "storage"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Appearance ----------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.show_custom_menu_links",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["menu", "links", "custom", "navigation"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.appearance.merge_next_up_continue_watching",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["continue", "watching", "next", "up", "merge", "home"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.other.hide_libraries",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["hide", "libraries", "library", "home"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.appearance.hide_remote_session_button",
|
|
||||||
parentRoute: "/settings/appearance/page",
|
|
||||||
parentTitleKey: "home.settings.appearance.title",
|
|
||||||
keywords: ["remote", "session", "button", "hide", "cast"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Network -------------------------------------------------------------
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.remote_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["remote", "url", "server", "address"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.active_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["active", "url", "server", "connection"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.auto_switch_enabled",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["auto", "switch", "local", "wifi", "network"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
titleKey: "home.settings.network.local_url",
|
|
||||||
parentRoute: "/settings/network/page",
|
|
||||||
parentTitleKey: "home.settings.network.title",
|
|
||||||
keywords: ["local", "url", "lan", "server", "address"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { matchesQuery } from "./searchFilter";
|
|
||||||
import { SETTINGS_CATALOG, type SettingsTarget } from "./settingsCatalog";
|
|
||||||
import { SETTINGS_SEARCH_INDEX } from "./settingsSearchIndex";
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
subtitle?: string;
|
|
||||||
target: SettingsTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsSearch = (query: string): SearchResult[] => {
|
|
||||||
const os: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!query.trim()) return [];
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
for (const section of SETTINGS_CATALOG) {
|
|
||||||
for (const e of section.entries) {
|
|
||||||
if (e.platforms && !e.platforms.includes(os)) continue;
|
|
||||||
const title = t(e.titleKey);
|
|
||||||
if (matchesQuery({ title, keywords: e.keywords }, query)) {
|
|
||||||
results.push({ id: e.id, title, icon: e.icon, target: e.target });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const o of SETTINGS_SEARCH_INDEX) {
|
|
||||||
if (o.platforms && !o.platforms.includes(os)) continue;
|
|
||||||
const title = t(o.titleKey);
|
|
||||||
if (matchesQuery({ title, keywords: o.keywords }, query)) {
|
|
||||||
results.push({
|
|
||||||
id: `${o.parentRoute}#${o.titleKey}`,
|
|
||||||
title,
|
|
||||||
icon: "search",
|
|
||||||
subtitle: t(o.parentTitleKey),
|
|
||||||
target: { type: "route", route: o.parentRoute },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}, [query, os]);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user