mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
Compare commits
1 Commits
refactor-c
...
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
|
|
||||||
41
.github/copilot-instructions.md
vendored
41
.github/copilot-instructions.md
vendored
@@ -3,7 +3,7 @@
|
|||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
|
||||||
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs,
|
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
|
||||||
and provides seamless media streaming with offline capabilities and Chromecast support.
|
and provides seamless media streaming with offline capabilities and Chromecast support.
|
||||||
|
|
||||||
## Main Technologies
|
## Main Technologies
|
||||||
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- `scripts/` – Automation scripts (Node.js, Bash)
|
- `scripts/` – Automation scripts (Node.js, Bash)
|
||||||
- `plugins/` – Expo/Metro plugins
|
- `plugins/` – Expo/Metro plugins
|
||||||
|
|
||||||
## Code Quality Standards
|
## Coding Standards
|
||||||
|
|
||||||
**CRITICAL: Code must be production-ready, reliable, and maintainable**
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- Use TypeScript for ALL files (no .js files)
|
- Use TypeScript for ALL files (no .js files)
|
||||||
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
|
|
||||||
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
|
|
||||||
- When facing type issues, create proper type definitions and helper functions instead of using `any`
|
|
||||||
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
|
|
||||||
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
|
|
||||||
- Enable and respect strict TypeScript compiler options
|
|
||||||
- Define explicit return types for functions
|
|
||||||
- Use discriminated unions for complex state
|
|
||||||
|
|
||||||
### Code Reliability
|
|
||||||
- Implement comprehensive error handling with try-catch blocks
|
|
||||||
- Validate all external inputs (API responses, user input, query params)
|
|
||||||
- Handle edge cases explicitly (empty arrays, null, undefined)
|
|
||||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
|
|
||||||
- Add runtime checks for critical operations
|
|
||||||
- Implement proper loading and error states in components
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
- Use descriptive English names for variables, functions, and components
|
- Use descriptive English names for variables, functions, and components
|
||||||
- Prefer functional React components with hooks
|
- Prefer functional React components with hooks
|
||||||
- Use Jotai atoms for global state management
|
- Use Jotai atoms for global state management
|
||||||
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
|
|||||||
- Follow BiomeJS formatting and linting rules
|
- Follow BiomeJS formatting and linting rules
|
||||||
- Use `const` over `let`, avoid `var` entirely
|
- Use `const` over `let`, avoid `var` entirely
|
||||||
- Implement proper error boundaries
|
- Implement proper error boundaries
|
||||||
- Use React.memo() for performance optimization when needed
|
- Use React.memo() for performance optimization
|
||||||
- Handle both mobile and TV navigation patterns
|
- Handle both mobile and TV navigation patterns
|
||||||
- Write self-documenting code with clear intent
|
|
||||||
- Add comments only when code complexity requires explanation
|
|
||||||
|
|
||||||
## API Integration
|
## API Integration
|
||||||
|
|
||||||
@@ -108,18 +85,6 @@ Exemples:
|
|||||||
- `fix(auth): handle expired JWT tokens`
|
- `fix(auth): handle expired JWT tokens`
|
||||||
- `chore(deps): update Jellyfin SDK`
|
- `chore(deps): update Jellyfin SDK`
|
||||||
|
|
||||||
## Internationalization (i18n)
|
|
||||||
|
|
||||||
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
|
|
||||||
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
|
|
||||||
- **NEVER add or remove keys** - Crowdin manages the key structure
|
|
||||||
- **Editing translation values is safe** - Bidirectional sync handles merges
|
|
||||||
- Prefer letting Crowdin translators update values, but direct edits work if needed
|
|
||||||
- **Crowdin workflow**:
|
|
||||||
- New keys added to `en.json` sync to Crowdin automatically
|
|
||||||
- Approved translations sync back to language files via GitHub integration
|
|
||||||
- The source of truth is `en.json` for structure, Crowdin for translations
|
|
||||||
|
|
||||||
## Special Instructions
|
## Special Instructions
|
||||||
|
|
||||||
- Prioritize cross-platform compatibility (mobile + TV)
|
- Prioritize cross-platform compatibility (mobile + TV)
|
||||||
|
|||||||
93
.github/pull_request_template.md
vendored
93
.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?
|
A concise description of the changes introduced by this PR.
|
||||||
Uncomment the line below if AI was used to assist with this PR:
|
Example:
|
||||||
-->
|
“Add real-time currency conversion widget to dashboard.”
|
||||||
<!--
|
|
||||||
[](#) -->
|
|
||||||
|
|
||||||
## 📝 Description
|
|
||||||
<!--
|
|
||||||
A short description of the changes and why you're making them.
|
|
||||||
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 🏷️ 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,7 +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()}
|
||||||
|
className='pl-0.5'
|
||||||
|
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||||
|
>
|
||||||
|
<Feather name='chevron-left' size={28} color='white' />
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -250,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,19 +59,17 @@ function SettingsMobile() {
|
|||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
{Platform.OS !== "ios" && (
|
<TouchableOpacity
|
||||||
<View className='mb-4'>
|
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
|
||||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
|
||||||
<ListItem
|
>
|
||||||
onPress={() =>
|
<Text className='text-white font-bold text-base mb-1'>
|
||||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
{t("pairing.pair_with_phone_title")}
|
||||||
}
|
</Text>
|
||||||
title={t("pairing.pair_with_phone")}
|
<Text className='text-neutral-400 text-sm'>
|
||||||
textColor='blue'
|
{t("pairing.pair_with_phone_description")}
|
||||||
/>
|
</Text>
|
||||||
</ListGroup>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create skip options for a specific segment type
|
|
||||||
* Reduces code duplication across all 5 segment types
|
|
||||||
*/
|
|
||||||
const useSkipOptions = (
|
|
||||||
settingKey:
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview",
|
|
||||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
|
||||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
) => {
|
|
||||||
return useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
selected: option.value === settings?.[settingKey],
|
|
||||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SegmentSkipPage() {
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: t("home.settings.other.segment_skip_settings"),
|
|
||||||
});
|
|
||||||
}, [navigation, t]);
|
|
||||||
|
|
||||||
const skipIntroOptions = useSkipOptions(
|
|
||||||
"skipIntro",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipOutroOptions = useSkipOptions(
|
|
||||||
"skipOutro",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipRecapOptions = useSkipOptions(
|
|
||||||
"skipRecap",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipCommercialOptions = useSkipOptions(
|
|
||||||
"skipCommercial",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipPreviewOptions = useSkipOptions(
|
|
||||||
"skipPreview",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={false} className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_intro")}
|
|
||||||
subtitle={t("home.settings.other.skip_intro_description")}
|
|
||||||
disabled={pluginSettings?.skipIntro?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipIntroOptions}
|
|
||||||
disabled={pluginSettings?.skipIntro?.locked}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_intro")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_outro")}
|
|
||||||
subtitle={t("home.settings.other.skip_outro_description")}
|
|
||||||
disabled={pluginSettings?.skipOutro?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipOutroOptions}
|
|
||||||
disabled={pluginSettings?.skipOutro?.locked}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_outro")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_recap")}
|
|
||||||
subtitle={t("home.settings.other.skip_recap_description")}
|
|
||||||
disabled={pluginSettings?.skipRecap?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipRecapOptions}
|
|
||||||
disabled={pluginSettings?.skipRecap?.locked}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_recap")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_commercial")}
|
|
||||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
|
||||||
disabled={pluginSettings?.skipCommercial?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipCommercialOptions}
|
|
||||||
disabled={pluginSettings?.skipCommercial?.locked}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_commercial")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_preview")}
|
|
||||||
subtitle={t("home.settings.other.skip_preview_description")}
|
|
||||||
disabled={pluginSettings?.skipPreview?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipPreviewOptions}
|
|
||||||
disabled={pluginSettings?.skipPreview?.locked}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_preview")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEGMENT_SKIP_OPTIONS = (
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
): Array<{
|
|
||||||
label: string;
|
|
||||||
value: "none" | "ask" | "auto";
|
|
||||||
}> => [
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_auto"),
|
|
||||||
value: "auto",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_ask"),
|
|
||||||
value: "ask",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_none"),
|
|
||||||
value: "none",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,16 +3,14 @@ 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";
|
||||||
import { CastAutoplayWatcher } from "@/components/casting/CastAutoplayWatcher";
|
|
||||||
import { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -141,8 +139,6 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<CastingMiniPlayer />
|
|
||||||
<CastAutoplayWatcher />
|
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,768 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Player Modal
|
|
||||||
* Protocol-agnostic full-screen player for all supported casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { router, Stack } from "expo-router";
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ActivityIndicator, ScrollView, View } from "react-native";
|
|
||||||
import { GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import GoogleCast, {
|
|
||||||
CastState,
|
|
||||||
MediaPlayerState,
|
|
||||||
useCastDevice,
|
|
||||||
useCastState,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import Animated from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import { CastPlayerEpisodeControls } from "@/components/casting/player/CastPlayerEpisodeControls";
|
|
||||||
import { CastPlayerHeader } from "@/components/casting/player/CastPlayerHeader";
|
|
||||||
import { CastPlayerPoster } from "@/components/casting/player/CastPlayerPoster";
|
|
||||||
import { CastPlayerProgressBar } from "@/components/casting/player/CastPlayerProgressBar";
|
|
||||||
import { CastPlayerTitle } from "@/components/casting/player/CastPlayerTitle";
|
|
||||||
import { CastPlayerTransportControls } from "@/components/casting/player/CastPlayerTransportControls";
|
|
||||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
|
||||||
import { ChromecastDeviceSheet } from "@/components/chromecast/ChromecastDeviceSheet";
|
|
||||||
import { ChromecastEpisodeList } from "@/components/chromecast/ChromecastEpisodeList";
|
|
||||||
import { ChromecastSettingsMenu } from "@/components/chromecast/ChromecastSettingsMenu";
|
|
||||||
import { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { AutoplayCountdown } from "@/components/player/AutoplayCountdown";
|
|
||||||
import { useCastDismissGesture } from "@/hooks/useCastDismissGesture";
|
|
||||||
import { useCastEpisodes } from "@/hooks/useCastEpisodes";
|
|
||||||
import { useCasting } from "@/hooks/useCasting";
|
|
||||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
|
||||||
import { useCastPlayerProgress } from "@/hooks/useCastPlayerProgress";
|
|
||||||
import { useCastSelection } from "@/hooks/useCastSelection";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { castAutoplayAtom } from "@/utils/atoms/castAutoplay";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { detectCapabilities } from "@/utils/casting/capabilities";
|
|
||||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
|
||||||
import { getPosterUrl } from "@/utils/casting/helpers";
|
|
||||||
import { resolveSelection } from "@/utils/casting/selection";
|
|
||||||
import type { CastSelection } from "@/utils/casting/types";
|
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
|
||||||
import {
|
|
||||||
type PlaybackController,
|
|
||||||
useRegisterPlaybackController,
|
|
||||||
} from "@/utils/playback/playbackController";
|
|
||||||
|
|
||||||
export default function CastingPlayerScreen() {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { settings, updateSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Chromecast autoplay countdown — watcher hook drives this atom; we render
|
|
||||||
// the overlay here when set, and handle Play-now / Cancel from the user.
|
|
||||||
const castAutoplay = useAtomValue(castAutoplayAtom);
|
|
||||||
const setCastAutoplay = useSetAtom(castAutoplayAtom);
|
|
||||||
|
|
||||||
// Get raw Chromecast state directly - same as old implementation
|
|
||||||
const castState = useCastState();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
// Keep hook active for connection - used by remoteMediaClient from useCasting
|
|
||||||
useRemoteMediaClient();
|
|
||||||
|
|
||||||
// Fetch full item data from Jellyfin by ID and derive the effective item
|
|
||||||
const { fetchedItem, currentItem } = useCastPlayerItem({
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
mediaStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derive state from raw Chromecast hooks
|
|
||||||
const duration = mediaStatus?.mediaInfo?.streamDuration ?? 0;
|
|
||||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
|
||||||
const isBuffering = mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
|
||||||
const currentDevice = castDevice?.friendlyName ?? null;
|
|
||||||
|
|
||||||
// Progress/slider/trickplay cluster: slider shared values, scrub state,
|
|
||||||
// live-progress interpolation, resume-position tracking, trickplay preview.
|
|
||||||
const {
|
|
||||||
sliderProgress,
|
|
||||||
sliderMin,
|
|
||||||
sliderMax,
|
|
||||||
isScrubbing,
|
|
||||||
trickplayTime,
|
|
||||||
setTrickplayTime,
|
|
||||||
progress,
|
|
||||||
resumePositionRef,
|
|
||||||
trickPlayUrl,
|
|
||||||
calculateTrickplayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
} = useCastPlayerProgress({ mediaStatus, fetchedItem, duration });
|
|
||||||
|
|
||||||
// Only use casting controls if we have a current item to avoid "No session" errors
|
|
||||||
const castingControls = useCasting(currentItem);
|
|
||||||
const {
|
|
||||||
togglePlayPause,
|
|
||||||
skipForward,
|
|
||||||
skipBackward,
|
|
||||||
setVolume,
|
|
||||||
volume,
|
|
||||||
remoteMediaClient,
|
|
||||||
} = currentItem
|
|
||||||
? castingControls
|
|
||||||
: {
|
|
||||||
togglePlayPause: async () => {},
|
|
||||||
skipForward: async () => {},
|
|
||||||
skipBackward: async () => {},
|
|
||||||
setVolume: () => {},
|
|
||||||
volume: 1,
|
|
||||||
remoteMediaClient: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Modal states
|
|
||||||
const [showEpisodeList, setShowEpisodeList] = useState(false);
|
|
||||||
const [showDeviceSheet, setShowDeviceSheet] = useState(false);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
|
||||||
|
|
||||||
// Chapter markers (shown for both episodes and movies).
|
|
||||||
const chapters = currentItem?.Chapters;
|
|
||||||
const hasChapters = chapterMarkers(chapters, duration * 1000).length > 1;
|
|
||||||
|
|
||||||
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);
|
|
||||||
|
|
||||||
// Reload the cast stream with a full selection; resolves true on success.
|
|
||||||
const reloadWithSelection = useCallback(
|
|
||||||
async (selection: CastSelection): Promise<boolean> => {
|
|
||||||
if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) {
|
|
||||||
console.warn("[Casting Player] Cannot reload - missing required data");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const currentPosition = resumePositionRef.current;
|
|
||||||
const result = await loadCastMedia({
|
|
||||||
client: remoteMediaClient,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
|
||||||
item: currentItem,
|
|
||||||
userId: user.Id,
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: {
|
|
||||||
mediaSourceId: selection.mediaSourceId,
|
|
||||||
audioStreamIndex: selection.audioStreamIndex,
|
|
||||||
subtitleStreamIndex: selection.subtitleStreamIndex,
|
|
||||||
maxBitrate: selection.maxBitrate,
|
|
||||||
startPositionMs: currentPosition * 1000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to reload stream:",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
currentItem,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { currentSelection, applySelection } = useCastSelection({
|
|
||||||
currentItem,
|
|
||||||
mediaStatus,
|
|
||||||
reload: reloadWithSelection,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Episode/season cluster: episode list, next episode, season data, loader
|
|
||||||
const { episodes, nextEpisode, seasonData, loadEpisode, loadingEpisodeId } =
|
|
||||||
useCastEpisodes({
|
|
||||||
api,
|
|
||||||
user,
|
|
||||||
currentItem,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
// True while a `loadEpisode` is in flight and `currentItem` (derived from the
|
|
||||||
// cast customData) still describes the previous episode. Used to suppress
|
|
||||||
// episode-dependent secondary UI that would otherwise flash stale data.
|
|
||||||
const isEpisodeTransitioning =
|
|
||||||
loadingEpisodeId != null && loadingEpisodeId !== currentItem?.Id;
|
|
||||||
|
|
||||||
// Expose this player to the app-wide remote-control surface while a cast
|
|
||||||
// session is connected. The individual useCasting methods are each
|
|
||||||
// useCallback-wrapped and stable, so depend on them directly rather than on
|
|
||||||
// the whole `castingControls` object literal (rebuilt every render).
|
|
||||||
const {
|
|
||||||
togglePlayPause: castTogglePlayPause,
|
|
||||||
pause: castPause,
|
|
||||||
play: castPlay,
|
|
||||||
stop: castStop,
|
|
||||||
seek: castSeek,
|
|
||||||
setVolume: castSetVolume,
|
|
||||||
} = castingControls;
|
|
||||||
// toggleMute reads the latest volume without making `volume` a useMemo dep.
|
|
||||||
const volumeRef = useRef(volume);
|
|
||||||
volumeRef.current = volume;
|
|
||||||
|
|
||||||
const castController = useMemo<PlaybackController>(
|
|
||||||
() => ({
|
|
||||||
playPause: () => {
|
|
||||||
castTogglePlayPause();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
castPause();
|
|
||||||
},
|
|
||||||
unpause: () => {
|
|
||||||
castPlay();
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
castStop();
|
|
||||||
},
|
|
||||||
seek: (positionMs) => {
|
|
||||||
castSeek(positionMs);
|
|
||||||
},
|
|
||||||
next: () => {
|
|
||||||
if (nextEpisode) loadEpisode(nextEpisode);
|
|
||||||
},
|
|
||||||
previous: () => {
|
|
||||||
const idx = episodes.findIndex((e) => e.Id === currentItem?.Id);
|
|
||||||
if (idx > 0) loadEpisode(episodes[idx - 1]);
|
|
||||||
},
|
|
||||||
setVolume: (level) => {
|
|
||||||
castSetVolume(level);
|
|
||||||
},
|
|
||||||
toggleMute: () => {
|
|
||||||
castSetVolume(volumeRef.current > 0 ? 0 : 1);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
castTogglePlayPause,
|
|
||||||
castPause,
|
|
||||||
castPlay,
|
|
||||||
castStop,
|
|
||||||
castSeek,
|
|
||||||
castSetVolume,
|
|
||||||
episodes,
|
|
||||||
nextEpisode,
|
|
||||||
loadEpisode,
|
|
||||||
currentItem?.Id,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
useRegisterPlaybackController(
|
|
||||||
castController,
|
|
||||||
castState === CastState.CONNECTED,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The MediaSource currently selected, for deriving its tracks.
|
|
||||||
// Derived from fetchedItem: the slim cast-customData item strips per-source
|
|
||||||
// MediaStreams, so only the full fetched item yields correct track lists.
|
|
||||||
const selectedSource = useMemo(
|
|
||||||
() =>
|
|
||||||
fetchedItem?.MediaSources?.find(
|
|
||||||
(s) => s.Id === currentSelection?.mediaSourceId,
|
|
||||||
) ??
|
|
||||||
fetchedItem?.MediaSources?.[0] ??
|
|
||||||
null,
|
|
||||||
[fetchedItem?.MediaSources, currentSelection?.mediaSourceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Real alternate versions (multi-version items).
|
|
||||||
const availableVersions = useMemo(
|
|
||||||
() =>
|
|
||||||
(fetchedItem?.MediaSources ?? []).map((s, i) => ({
|
|
||||||
id: s.Id ?? `source-${i}`,
|
|
||||||
name: s.Name || `${t("casting_player.version")} ${i + 1}`,
|
|
||||||
})),
|
|
||||||
[fetchedItem?.MediaSources, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Quality tiers from the shared ladder, capped to BOTH the device's
|
|
||||||
// capability and the media's own bitrate — a tier above either ceiling
|
|
||||||
// would behave identically to "Max", so it is not offered.
|
|
||||||
const availableQualities = useMemo(() => {
|
|
||||||
const caps = detectCapabilities(castDevice, {
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrate: settings.chromecastMaxBitrate,
|
|
||||||
});
|
|
||||||
const mediaBitrate =
|
|
||||||
selectedSource?.Bitrate ??
|
|
||||||
fetchedItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ??
|
|
||||||
Number.POSITIVE_INFINITY;
|
|
||||||
const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate);
|
|
||||||
return BITRATES.filter((b) => b.value === undefined || b.value <= ceiling);
|
|
||||||
}, [
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
selectedSource,
|
|
||||||
fetchedItem?.MediaStreams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const availableAudioTracks = useMemo(() => {
|
|
||||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
|
||||||
if (!streams) return [];
|
|
||||||
return streams
|
|
||||||
.filter((stream) => stream.Type === "Audio")
|
|
||||||
.map((stream) => ({
|
|
||||||
index: stream.Index ?? 0,
|
|
||||||
language: stream.Language || "Unknown",
|
|
||||||
displayTitle:
|
|
||||||
stream.DisplayTitle ||
|
|
||||||
`${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(),
|
|
||||||
codec: stream.Codec || "Unknown",
|
|
||||||
channels: stream.Channels,
|
|
||||||
bitrate: stream.BitRate,
|
|
||||||
}));
|
|
||||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
|
||||||
|
|
||||||
const availableSubtitleTracks = useMemo(() => {
|
|
||||||
const streams = selectedSource?.MediaStreams ?? fetchedItem?.MediaStreams;
|
|
||||||
if (!streams) return [];
|
|
||||||
return streams
|
|
||||||
.filter((stream) => stream.Type === "Subtitle")
|
|
||||||
.map((stream) => ({
|
|
||||||
index: stream.Index ?? 0,
|
|
||||||
language: stream.Language || "Unknown",
|
|
||||||
displayTitle:
|
|
||||||
stream.DisplayTitle ||
|
|
||||||
[
|
|
||||||
stream.Language || "Unknown",
|
|
||||||
stream.IsForced ? " (Forced)" : "",
|
|
||||||
stream.Title ? ` - ${stream.Title}` : "",
|
|
||||||
].join(""),
|
|
||||||
codec: stream.Codec || "Unknown",
|
|
||||||
isForced: stream.IsForced || false,
|
|
||||||
isExternal: stream.IsExternal || false,
|
|
||||||
}));
|
|
||||||
}, [selectedSource, fetchedItem?.MediaStreams]);
|
|
||||||
|
|
||||||
// Autoplay overlay's "Play now" — load the queued next episode immediately.
|
|
||||||
// Mirrors `useCastEpisodes.loadEpisode` exactly (same `loadCastMedia` shape,
|
|
||||||
// same start-position derivation) so the cast load is identical regardless
|
|
||||||
// of whether it is triggered by the user or by the countdown timer.
|
|
||||||
const onAutoplayPlayNow = useCallback(async () => {
|
|
||||||
if (!castAutoplay) return;
|
|
||||||
const episode = castAutoplay.nextEpisode;
|
|
||||||
if (!api || !user?.Id || !remoteMediaClient || !episode?.Id) {
|
|
||||||
setCastAutoplay(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const startPositionMs =
|
|
||||||
(episode.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
|
||||||
const result = await loadCastMedia({
|
|
||||||
client: remoteMediaClient,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
|
||||||
item: episode,
|
|
||||||
userId: user.Id,
|
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: { startPositionMs },
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to load next episode (play now):",
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Reset the autoplay counter on explicit user action.
|
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Failed to load next episode (play now):",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setCastAutoplay(null);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
castAutoplay,
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
remoteMediaClient,
|
|
||||||
castDevice,
|
|
||||||
settings.chromecastProfile,
|
|
||||||
settings.chromecastMaxBitrate,
|
|
||||||
updateSettings,
|
|
||||||
setCastAutoplay,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Poster URL for the queued next episode (mirrors `posterUrl` for the
|
|
||||||
// currently-playing item — same helper, same dimensions).
|
|
||||||
const autoplayPosterUrl = useMemo(() => {
|
|
||||||
if (!castAutoplay || !api?.basePath) return null;
|
|
||||||
const ep = castAutoplay.nextEpisode;
|
|
||||||
// `BaseItemDto.Id` is `string | undefined`; bail if missing so we never
|
|
||||||
// call the helper with `undefined`. AutoplayCountdown handles null.
|
|
||||||
if (!ep?.Id) return null;
|
|
||||||
return getPosterUrl(api.basePath, ep.Id, ep.ImageTags?.Primary, 260, 390);
|
|
||||||
}, [castAutoplay, api?.basePath]);
|
|
||||||
|
|
||||||
// NOTE: Auto-navigation to casting-player is handled by higher-level
|
|
||||||
// components (e.g., CastingMiniPlayer or Chromecast button). We intentionally
|
|
||||||
// do NOT call router.replace("/casting-player") here because this component
|
|
||||||
// IS the casting-player screen — doing so would cause redundant navigation loops.
|
|
||||||
|
|
||||||
// Segment detection (skip intro/credits) - use progress in seconds for accurate detection
|
|
||||||
const { currentSegment, skipIntro, skipCredits, skipSegment } =
|
|
||||||
useChromecastSegments(currentItem, progress * 1000, false);
|
|
||||||
|
|
||||||
// Swipe down to dismiss gesture
|
|
||||||
const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({
|
|
||||||
router,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Memoize expensive calculations (before early return)
|
|
||||||
const posterUrl = useMemo(() => {
|
|
||||||
if (!api?.basePath || !currentItem?.Id) return null;
|
|
||||||
|
|
||||||
// For episodes, use SEASON poster instead of episode poster
|
|
||||||
if (currentItem.Type === "Episode" && seasonData?.Id) {
|
|
||||||
// Use ParentPrimaryImageItemId if available, otherwise use season's own ImageTags
|
|
||||||
const imageItemId = seasonData.ParentPrimaryImageItemId || seasonData.Id;
|
|
||||||
const seasonImageTag = seasonData.ImageTags?.Primary;
|
|
||||||
return seasonImageTag
|
|
||||||
? `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96&tag=${seasonImageTag}`
|
|
||||||
: `${api.basePath}/Items/${imageItemId}/Images/Primary?fillHeight=450&fillWidth=300&quality=96`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to item poster for non-episodes or if season data not loaded
|
|
||||||
return getPosterUrl(
|
|
||||||
api.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
260,
|
|
||||||
390,
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
api?.basePath,
|
|
||||||
currentItem?.Id,
|
|
||||||
currentItem?.Type,
|
|
||||||
seasonData?.Id,
|
|
||||||
seasonData?.ImageTags?.Primary,
|
|
||||||
currentItem?.ImageTags?.Primary,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
||||||
|
|
||||||
// Redirect if not connected - check CastState like old implementation
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect immediately when disconnected or no devices
|
|
||||||
if (
|
|
||||||
castState === CastState.NOT_CONNECTED ||
|
|
||||||
castState === CastState.NO_DEVICES_AVAILABLE
|
|
||||||
) {
|
|
||||||
// Use setTimeout to avoid state update during render
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [castState, router]);
|
|
||||||
|
|
||||||
// Also redirect if mediaStatus disappears (media ended or stopped)
|
|
||||||
useEffect(() => {
|
|
||||||
if (castState === CastState.CONNECTED && !mediaStatus) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, 500); // Small delay to allow for media transitions
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [castState, mediaStatus, router]);
|
|
||||||
|
|
||||||
// Show loading while connecting
|
|
||||||
if (castState === CastState.CONNECTING) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size='large' color='#fff' />
|
|
||||||
<Text style={{ color: "#fff", marginTop: 16 }}>
|
|
||||||
{t("casting_player.connecting")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't render if not connected or no media playing
|
|
||||||
if (castState !== CastState.CONNECTED || !mediaStatus || !currentItem) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack.Screen
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
presentation: "fullScreenModal",
|
|
||||||
animation: "slide_from_bottom",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<GestureDetector gesture={panGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* Header - Fixed at top */}
|
|
||||||
<CastPlayerHeader
|
|
||||||
insetTop={insets.top}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
currentDevice={currentDevice}
|
|
||||||
t={t}
|
|
||||||
onDismiss={dismissModal}
|
|
||||||
onPressConnectionIndicator={() => setShowDeviceSheet(true)}
|
|
||||||
onPressSettings={() => setShowSettings(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Title Area — hidden during an episode change to avoid flashing
|
|
||||||
the previous episode's title/season-episode numbers. */}
|
|
||||||
{!isEpisodeTransitioning && (
|
|
||||||
<CastPlayerTitle
|
|
||||||
insetTop={insets.top}
|
|
||||||
currentItem={currentItem}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable content area */}
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingTop: insets.top + 160,
|
|
||||||
paddingBottom: insets.bottom + 500,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* Poster with buffering overlay — force the overlay during an
|
|
||||||
episode change so the loading state covers the stale poster. */}
|
|
||||||
<CastPlayerPoster
|
|
||||||
posterUrl={posterUrl}
|
|
||||||
isBuffering={isBuffering || isEpisodeTransitioning}
|
|
||||||
currentSegment={currentSegment}
|
|
||||||
skipIntro={skipIntro}
|
|
||||||
skipCredits={skipCredits}
|
|
||||||
skipSegment={skipSegment}
|
|
||||||
remoteMediaClient={remoteMediaClient}
|
|
||||||
mediaStatus={mediaStatus}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Fixed control row - positioned independently. Episode-specific
|
|
||||||
buttons are conditional inside; Stop is always available. */}
|
|
||||||
<CastPlayerEpisodeControls
|
|
||||||
insetBottom={insets.bottom}
|
|
||||||
currentItemId={currentItem.Id}
|
|
||||||
episodes={episodes}
|
|
||||||
nextEpisode={nextEpisode}
|
|
||||||
remoteMediaClient={remoteMediaClient}
|
|
||||||
onPressEpisodes={() => setShowEpisodeList(true)}
|
|
||||||
hasChapters={hasChapters}
|
|
||||||
onPressChapters={() => setChapterListVisible(true)}
|
|
||||||
loadEpisode={loadEpisode}
|
|
||||||
router={router}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Fixed bottom controls area */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 10,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
zIndex: 98,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Progress slider with trickplay preview + time display */}
|
|
||||||
<CastPlayerProgressBar
|
|
||||||
sliderProgress={sliderProgress}
|
|
||||||
sliderMin={sliderMin}
|
|
||||||
sliderMax={sliderMax}
|
|
||||||
isScrubbing={isScrubbing}
|
|
||||||
trickplayTime={trickplayTime}
|
|
||||||
setTrickplayTime={setTrickplayTime}
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
calculateTrickplayUrl={calculateTrickplayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
progress={progress}
|
|
||||||
duration={duration}
|
|
||||||
remoteMediaClient={remoteMediaClient}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
chapters={currentItem?.Chapters}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Playback controls */}
|
|
||||||
<CastPlayerTransportControls
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
togglePlayPause={togglePlayPause}
|
|
||||||
skipBackward={skipBackward}
|
|
||||||
skipForward={skipForward}
|
|
||||||
rewindSkipTime={settings?.rewindSkipTime}
|
|
||||||
forwardSkipTime={settings?.forwardSkipTime}
|
|
||||||
protocolColor={protocolColor}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Autoplay countdown overlay — bottom-centred above the episode
|
|
||||||
control row and main controls. 320 wide card; centred via
|
|
||||||
left/right:0 + alignItems:"center". */}
|
|
||||||
{castAutoplay && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insets.bottom + 280,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
zIndex: 99,
|
|
||||||
}}
|
|
||||||
pointerEvents='box-none'
|
|
||||||
>
|
|
||||||
<AutoplayCountdown
|
|
||||||
nextEpisode={castAutoplay.nextEpisode}
|
|
||||||
posterUrl={autoplayPosterUrl}
|
|
||||||
secondsRemaining={castAutoplay.secondsRemaining}
|
|
||||||
onPlayNow={onAutoplayPlayNow}
|
|
||||||
onCancel={() => setCastAutoplay(null)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<ChromecastDeviceSheet
|
|
||||||
visible={showDeviceSheet}
|
|
||||||
onClose={() => setShowDeviceSheet(false)}
|
|
||||||
device={
|
|
||||||
currentDevice && castDevice
|
|
||||||
? { friendlyName: currentDevice }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
onDisconnect={async () => {
|
|
||||||
try {
|
|
||||||
// End the casting session and disconnect completely
|
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
|
||||||
await sessionManager.endCurrentSession(true);
|
|
||||||
setShowDeviceSheet(false);
|
|
||||||
// Close player immediately after disconnecting
|
|
||||||
setTimeout(() => {
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"[Casting Player] Error disconnecting from Chromecast:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
volume={volume}
|
|
||||||
onVolumeChange={async (vol) => {
|
|
||||||
try {
|
|
||||||
setVolume(vol);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Failed to set volume:", error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChromecastEpisodeList
|
|
||||||
visible={showEpisodeList}
|
|
||||||
onClose={() => setShowEpisodeList(false)}
|
|
||||||
currentItem={currentItem}
|
|
||||||
episodes={episodes}
|
|
||||||
api={api}
|
|
||||||
onSelectEpisode={async (episode) => {
|
|
||||||
setShowEpisodeList(false);
|
|
||||||
await loadEpisode(episode);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChapterList
|
|
||||||
visible={chapterListVisible}
|
|
||||||
chapters={chapters}
|
|
||||||
currentPositionMs={progress * 1000}
|
|
||||||
onSeek={(ms) => {
|
|
||||||
remoteMediaClient?.seek({ position: ms / 1000 });
|
|
||||||
}}
|
|
||||||
onClose={() => setChapterListVisible(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ChromecastSettingsMenu
|
|
||||||
visible={showSettings}
|
|
||||||
onClose={() => setShowSettings(false)}
|
|
||||||
versions={availableVersions}
|
|
||||||
selectedVersionId={currentSelection?.mediaSourceId ?? ""}
|
|
||||||
onVersionChange={(id) => {
|
|
||||||
if (!fetchedItem) return;
|
|
||||||
applySelection({
|
|
||||||
...resolveSelection(fetchedItem, { mediaSourceId: id }),
|
|
||||||
maxBitrate: currentSelection?.maxBitrate,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
qualities={availableQualities}
|
|
||||||
selectedMaxBitrate={currentSelection?.maxBitrate}
|
|
||||||
onQualityChange={(value) => applySelection({ maxBitrate: value })}
|
|
||||||
audioTracks={isEpisodeTransitioning ? [] : availableAudioTracks}
|
|
||||||
selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1}
|
|
||||||
onAudioChange={(index) =>
|
|
||||||
applySelection({ audioStreamIndex: index })
|
|
||||||
}
|
|
||||||
subtitleTracks={
|
|
||||||
isEpisodeTransitioning ? [] : availableSubtitleTracks
|
|
||||||
}
|
|
||||||
selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1}
|
|
||||||
onSubtitleChange={(index) =>
|
|
||||||
applySelection({ subtitleStreamIndex: index })
|
|
||||||
}
|
|
||||||
playbackSpeed={currentPlaybackSpeed}
|
|
||||||
onPlaybackSpeedChange={(speed) => {
|
|
||||||
setCurrentPlaybackSpeed(speed);
|
|
||||||
remoteMediaClient?.setPlaybackRate(speed).catch(console.error);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,7 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
|||||||
import { useInactivity } from "@/providers/InactivityProvider";
|
import { useInactivity } from "@/providers/InactivityProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||||
|
|
||||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
@@ -59,14 +60,10 @@ import {
|
|||||||
getMpvSubtitleId,
|
getMpvSubtitleId,
|
||||||
} from "@/utils/jellyfin/subtitleUtils";
|
} from "@/utils/jellyfin/subtitleUtils";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
import {
|
|
||||||
type PlaybackController,
|
|
||||||
useRegisterPlaybackController,
|
|
||||||
} from "@/utils/playback/playbackController";
|
|
||||||
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);
|
||||||
@@ -188,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(() => {
|
||||||
|
if (audioIndex !== undefined) {
|
||||||
setCurrentAudioIndex(audioIndex);
|
setCurrentAudioIndex(audioIndex);
|
||||||
|
}
|
||||||
}, [audioIndex]);
|
}, [audioIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -320,7 +317,7 @@ export default function DirectPlayerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
||||||
@@ -406,6 +403,26 @@ export default function DirectPlayerPage() {
|
|||||||
reportPlaybackStart();
|
reportPlaybackStart();
|
||||||
}, [stream, api, offline]);
|
}, [stream, api, offline]);
|
||||||
|
|
||||||
|
const togglePlay = async () => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
if (isPlaying) {
|
||||||
|
await videoRef.current?.pause();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
|
if (progressInfo) {
|
||||||
|
playbackManager.reportPlaybackProgress(progressInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoRef.current?.play();
|
||||||
|
const progressInfo = currentPlayStateInfo();
|
||||||
|
if (!offline && api) {
|
||||||
|
await getPlaystateApi(api).reportPlaybackStart({
|
||||||
|
playbackStartInfo: progressInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const reportPlaybackStopped = useCallback(async () => {
|
const reportPlaybackStopped = useCallback(async () => {
|
||||||
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
if (!item?.Id || !stream?.sessionId || offline || !api) return;
|
||||||
|
|
||||||
@@ -453,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,
|
||||||
@@ -471,43 +485,14 @@ export default function DirectPlayerPage() {
|
|||||||
}, [
|
}, [
|
||||||
stream,
|
stream,
|
||||||
item?.Id,
|
item?.Id,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
progress,
|
progress,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
isMuted,
|
isMuted,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Declared after currentPlayStateInfo so the dependency array can reference
|
|
||||||
// it without hitting the temporal dead zone.
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
lightHapticFeedback();
|
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
if (isPlaying) {
|
|
||||||
await videoRef.current?.pause();
|
|
||||||
const progressInfo = currentPlayStateInfo();
|
|
||||||
if (progressInfo) {
|
|
||||||
playbackManager.reportPlaybackProgress(progressInfo);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play();
|
|
||||||
const progressInfo = currentPlayStateInfo();
|
|
||||||
if (!offline && api) {
|
|
||||||
await getPlaystateApi(api).reportPlaybackStart({
|
|
||||||
playbackStartInfo: progressInfo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
lightHapticFeedback,
|
|
||||||
isPlaying,
|
|
||||||
currentPlayStateInfo,
|
|
||||||
playbackManager,
|
|
||||||
offline,
|
|
||||||
api,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const lastUrlUpdateTime = useSharedValue(0);
|
const lastUrlUpdateTime = useSharedValue(0);
|
||||||
const wasJustSeeking = useSharedValue(false);
|
const wasJustSeeking = useSharedValue(false);
|
||||||
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
|
||||||
@@ -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?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -936,47 +920,6 @@ export default function DirectPlayerPage() {
|
|||||||
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
return (await videoRef.current?.getTechnicalInfo?.()) ?? {};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// App-wide remote control: wrap the player's existing handlers so remote
|
|
||||||
// commands (e.g. dashboard, WebSocket) route to whatever is playing.
|
|
||||||
const playbackController = useMemo<PlaybackController>(
|
|
||||||
() => ({
|
|
||||||
// togglePlay flips play/pause and reports progress to the server.
|
|
||||||
playPause: () => {
|
|
||||||
void togglePlay();
|
|
||||||
},
|
|
||||||
pause: () => {
|
|
||||||
pause();
|
|
||||||
},
|
|
||||||
unpause: () => {
|
|
||||||
play();
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
stop();
|
|
||||||
},
|
|
||||||
// PlaybackController seeks in ms; the player's seek already expects ms.
|
|
||||||
seek: (positionMs: number) => {
|
|
||||||
seek(positionMs);
|
|
||||||
},
|
|
||||||
// The player screen has no episode-loading path of its own — episode
|
|
||||||
// navigation is handled inside <Controls> via router replacement — so
|
|
||||||
// next/previous are honest no-ops here.
|
|
||||||
next: () => {},
|
|
||||||
previous: () => {},
|
|
||||||
// Volume is device-level (react-native-volume-manager); the slider sends
|
|
||||||
// 0-1 while setVolumeCb expects 0-100.
|
|
||||||
setVolume: (level: number) => {
|
|
||||||
void setVolumeCb(level * 100);
|
|
||||||
},
|
|
||||||
toggleMute: () => {
|
|
||||||
void toggleMuteCb();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[togglePlay, pause, play, stop, seek, setVolumeCb, toggleMuteCb],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Active for the whole lifetime of the player screen; cleared on unmount.
|
|
||||||
useRegisterPlaybackController(playbackController, true);
|
|
||||||
|
|
||||||
// Determine play method based on stream URL and media source
|
// Determine play method based on stream URL and media source
|
||||||
const playMethod = useMemo<
|
const playMethod = useMemo<
|
||||||
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
"DirectPlay" | "DirectStream" | "Transcode" | undefined
|
||||||
@@ -1066,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,
|
||||||
});
|
});
|
||||||
@@ -1087,8 +1029,8 @@ export default function DirectPlayerPage() {
|
|||||||
}, [
|
}, [
|
||||||
previousItem,
|
previousItem,
|
||||||
settings,
|
settings,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
@@ -1133,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,
|
||||||
});
|
});
|
||||||
@@ -1155,8 +1095,8 @@ export default function DirectPlayerPage() {
|
|||||||
}, [
|
}, [
|
||||||
nextItem,
|
nextItem,
|
||||||
settings,
|
settings,
|
||||||
currentSubtitleIndex,
|
subtitleIndex,
|
||||||
currentAudioIndex,
|
audioIndex,
|
||||||
stream?.mediaSource,
|
stream?.mediaSource,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
router,
|
router,
|
||||||
@@ -1307,13 +1247,12 @@ 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);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occurred_while_playing_the_video"),
|
t("player.an_error_occured_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("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"]
|
||||||
122
components/BitRateSheet.tsx
Normal file
122
components/BitRateSheet.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
import { FilterSheet } from "./filters/FilterSheet";
|
||||||
|
|
||||||
|
export type Bitrate = {
|
||||||
|
key: string;
|
||||||
|
value: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BITRATES: Bitrate[] = [
|
||||||
|
{
|
||||||
|
key: "Max",
|
||||||
|
value: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "8 Mb/s",
|
||||||
|
value: 8000000,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "4 Mb/s",
|
||||||
|
value: 4000000,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "2 Mb/s",
|
||||||
|
value: 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "1 Mb/s",
|
||||||
|
value: 1000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "500 Kb/s",
|
||||||
|
value: 500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "250 Kb/s",
|
||||||
|
value: 250000,
|
||||||
|
},
|
||||||
|
].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
|
onChange: (value: Bitrate) => void;
|
||||||
|
selected?: Bitrate | null;
|
||||||
|
inverted?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BitrateSheet: React.FC<Props> = ({
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
inverted,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const isTv = Platform.isTV;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (inverted)
|
||||||
|
return BITRATES.slice().sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(b.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
return BITRATES.slice().sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
(a.value || Number.POSITIVE_INFINITY),
|
||||||
|
);
|
||||||
|
}, [inverted]);
|
||||||
|
|
||||||
|
if (isTv) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex shrink'
|
||||||
|
style={{
|
||||||
|
minWidth: 60,
|
||||||
|
maxWidth: 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col' {...props}>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("item_card.quality")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text numberOfLines={1}>
|
||||||
|
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
title={t("item_card.quality")}
|
||||||
|
data={sorted}
|
||||||
|
values={selected ? [selected] : []}
|
||||||
|
multiple={false}
|
||||||
|
searchFilter={(item, query) => {
|
||||||
|
const label = (item as any).key || "";
|
||||||
|
return label.toLowerCase().includes(query.toLowerCase());
|
||||||
|
}}
|
||||||
|
renderItemLabel={(item) => <Text>{(item as any).key || ""}</Text>}
|
||||||
|
set={(vals) => {
|
||||||
|
const chosen = vals[0] as Bitrate | undefined;
|
||||||
|
if (chosen) onChange(chosen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,31 +10,36 @@ export type Bitrate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const BITRATES: Bitrate[] = [
|
export const BITRATES: Bitrate[] = [
|
||||||
{ key: "Max", value: undefined },
|
{
|
||||||
{ key: "200 Mb/s", value: 200000000 },
|
key: "Max",
|
||||||
{ key: "180 Mb/s", value: 180000000 },
|
value: undefined,
|
||||||
{ key: "140 Mb/s", value: 140000000 },
|
},
|
||||||
{ key: "120 Mb/s", value: 120000000 },
|
{
|
||||||
{ key: "110 Mb/s", value: 110000000 },
|
key: "8 Mb/s",
|
||||||
{ key: "100 Mb/s", value: 100000000 },
|
value: 8000000,
|
||||||
{ key: "90 Mb/s", value: 90000000 },
|
height: 1080,
|
||||||
{ key: "80 Mb/s", value: 80000000 },
|
},
|
||||||
{ key: "70 Mb/s", value: 70000000 },
|
{
|
||||||
{ key: "60 Mb/s", value: 60000000 },
|
key: "4 Mb/s",
|
||||||
{ key: "50 Mb/s", value: 50000000 },
|
value: 4000000,
|
||||||
{ key: "40 Mb/s", value: 40000000 },
|
height: 1080,
|
||||||
{ key: "30 Mb/s", value: 30000000 },
|
},
|
||||||
{ key: "20 Mb/s", value: 20000000 },
|
{
|
||||||
{ key: "15 Mb/s", value: 15000000 },
|
key: "2 Mb/s",
|
||||||
{ key: "10 Mb/s", value: 10000000 },
|
value: 2000000,
|
||||||
{ key: "8 Mb/s", value: 8000000 },
|
},
|
||||||
{ key: "5 Mb/s", value: 5000000 },
|
{
|
||||||
{ key: "4 Mb/s", value: 4000000 },
|
key: "1 Mb/s",
|
||||||
{ key: "3 Mb/s", value: 3000000 },
|
value: 1000000,
|
||||||
{ key: "2 Mb/s", value: 2000000 },
|
},
|
||||||
{ key: "1 Mb/s", value: 1000000 },
|
{
|
||||||
{ key: "720 Kb/s", value: 720000 },
|
key: "500 Kb/s",
|
||||||
{ key: "420 Kb/s", value: 420000 },
|
value: 500000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "250 Kb/s",
|
||||||
|
value: 250000,
|
||||||
|
},
|
||||||
].sort(
|
].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.value || Number.POSITIVE_INFINITY) -
|
(b.value || Number.POSITIVE_INFINITY) -
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { useCallback, useEffect } from "react";
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
CastButton,
|
CastButton,
|
||||||
CastContext,
|
CastContext,
|
||||||
CastState,
|
|
||||||
useCastDevice,
|
useCastDevice,
|
||||||
useCastState,
|
|
||||||
useDevices,
|
useDevices,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
@@ -26,136 +18,23 @@ export function Chromecast({
|
|||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
// Hooks called for their side effects (keep Chromecast session active)
|
const client = useRemoteMediaClient();
|
||||||
useRemoteMediaClient();
|
const castDevice = useCastDevice();
|
||||||
useCastDevice();
|
const devices = useDevices();
|
||||||
const castState = useCastState();
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
useDevices();
|
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
// Connection menu state
|
|
||||||
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
|
||||||
const isConnected = castState === CastState.CONNECTED;
|
|
||||||
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const lastReportedPlayerStateRef = useRef<string | null>(null);
|
|
||||||
const playSessionIdRef = useRef<string | null>(null);
|
|
||||||
const lastContentIdRef = useRef<string | null>(null);
|
|
||||||
const discoveryAttempts = useRef(0);
|
|
||||||
const maxDiscoveryAttempts = 3;
|
|
||||||
|
|
||||||
// Enhanced discovery with retry mechanism - runs once on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isSubscribed = true;
|
(async () => {
|
||||||
let retryTimeout: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const startDiscoveryWithRetry = async () => {
|
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// Stop any existing discovery first
|
|
||||||
try {
|
|
||||||
await discoveryManager.stopDiscovery();
|
|
||||||
} catch {
|
|
||||||
// Ignore errors when stopping
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start fresh discovery
|
|
||||||
await discoveryManager.startDiscovery();
|
await discoveryManager.startDiscovery();
|
||||||
discoveryAttempts.current = 0; // Reset on success
|
})();
|
||||||
} catch (error) {
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
console.error("[Chromecast Discovery] Failed:", error);
|
|
||||||
|
|
||||||
// Retry on error
|
|
||||||
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
|
|
||||||
discoveryAttempts.current++;
|
|
||||||
retryTimeout = setTimeout(() => {
|
|
||||||
if (isSubscribed) {
|
|
||||||
startDiscoveryWithRetry();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
startDiscoveryWithRetry();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
if (retryTimeout) {
|
|
||||||
clearTimeout(retryTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [discoveryManager]); // Only re-run if discoveryManager changes
|
|
||||||
|
|
||||||
// Report video progress to Jellyfin server
|
|
||||||
useEffect(() => {
|
|
||||||
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamPosition = mediaStatus.streamPosition || 0;
|
|
||||||
const playerState = mediaStatus.playerState || null;
|
|
||||||
|
|
||||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
|
||||||
const positionChanged =
|
|
||||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
|
||||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
|
||||||
if (!positionChanged && !stateChanged) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentId = mediaStatus.mediaInfo.contentId;
|
|
||||||
|
|
||||||
// Generate a new PlaySessionId when the content changes
|
|
||||||
if (contentId !== lastContentIdRef.current) {
|
|
||||||
// Use Math.random()-based UUID v4 (React Native lacks global crypto)
|
|
||||||
playSessionIdRef.current = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
|
|
||||||
/[xy]/g,
|
|
||||||
(c) => {
|
|
||||||
const r = (Math.random() * 16) | 0;
|
|
||||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
||||||
return v.toString(16);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
lastContentIdRef.current = contentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
|
||||||
const isPaused = mediaStatus.playerState === "paused";
|
|
||||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
|
||||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
|
||||||
|
|
||||||
const progressInfo: PlaybackProgressInfo = {
|
|
||||||
ItemId: contentId,
|
|
||||||
PositionTicks: positionTicks,
|
|
||||||
IsPaused: isPaused,
|
|
||||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
|
||||||
PlaySessionId: playSessionIdRef.current || contentId,
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlaystateApi(api)
|
|
||||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
|
||||||
.then(() => {
|
|
||||||
lastReportedProgressRef.current = streamPosition;
|
|
||||||
lastReportedPlayerStateRef.current = playerState;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to report Chromecast progress:", error);
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
mediaStatus?.mediaInfo?.contentId,
|
|
||||||
mediaStatus?.playerState,
|
|
||||||
mediaStatus?.mediaInfo?.contentUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
@@ -164,92 +43,50 @@ export function Chromecast({
|
|||||||
[Platform.OS],
|
[Platform.OS],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle press - show connection menu when connected, otherwise show cast dialog
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
if (isConnected) {
|
|
||||||
if (mediaStatus?.currentItemId) {
|
|
||||||
// Media is playing - navigate to full player
|
|
||||||
router.push("/casting-player");
|
|
||||||
} else {
|
|
||||||
// Connected but no media - show connection menu
|
|
||||||
setShowConnectionMenu(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Not connected - show cast dialog
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}, [isConnected, mediaStatus?.currentItemId]);
|
|
||||||
|
|
||||||
// Handle disconnect from Chromecast
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const sessionManager = GoogleCast.getSessionManager();
|
|
||||||
await sessionManager.endCurrentSession(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Chromecast] Disconnect error:", error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<>
|
<Pressable
|
||||||
<Pressable className='mr-4' onPress={handlePress} {...props}>
|
className='mr-4'
|
||||||
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
name='cast'
|
|
||||||
size={22}
|
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<ChromecastConnectionMenu
|
|
||||||
visible={showConnectionMenu}
|
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (background === "transparent")
|
if (background === "transparent")
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
className='mr-2'
|
className='mr-2'
|
||||||
background={false}
|
background={false}
|
||||||
onPress={handlePress}
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
name='cast'
|
|
||||||
size={22}
|
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
|
||||||
/>
|
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
<ChromecastConnectionMenu
|
|
||||||
visible={showConnectionMenu}
|
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RoundButton
|
||||||
<RoundButton size='large' onPress={handlePress} {...props}>
|
size='large'
|
||||||
|
onPress={() => {
|
||||||
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
|
else CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<AndroidCastButton />
|
<AndroidCastButton />
|
||||||
<Feather
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
name='cast'
|
|
||||||
size={22}
|
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
|
||||||
/>
|
|
||||||
</RoundButton>
|
</RoundButton>
|
||||||
<ChromecastConnectionMenu
|
|
||||||
visible={showConnectionMenu}
|
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type {
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||||
import { BITRATES } from "@/components/BitrateSelector";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { BITRATES } from "./BitRateSheet";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
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, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
@@ -12,17 +20,6 @@ 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";
|
||||||
@@ -63,7 +60,6 @@ interface PlatformDropdownProps {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
onOptionSelect?: (value?: any) => void;
|
onOptionSelect?: (value?: any) => void;
|
||||||
disabled?: boolean;
|
|
||||||
expoUIConfig?: {
|
expoUIConfig?: {
|
||||||
hostStyle?: any;
|
hostStyle?: any;
|
||||||
};
|
};
|
||||||
@@ -214,9 +210,6 @@ const PlatformDropdownComponent = ({
|
|||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
onOptionSelect,
|
onOptionSelect,
|
||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
// Aliased to avoid shadowing the module-level `disabled` SwiftUI modifier
|
|
||||||
// (from @expo/ui/swift-ui/modifiers) used by the iOS <Menu> renderer below.
|
|
||||||
disabled: isDisabled,
|
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
@@ -268,36 +261,25 @@ const PlatformDropdownComponent = ({
|
|||||||
}
|
}
|
||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
if (Platform.OS === "ios") {
|
||||||
if (isDisabled) {
|
|
||||||
return (
|
return (
|
||||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
<View>
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
{/* Hidden measurer: lays the trigger out normally to capture its
|
||||||
</View>
|
intrinsic size, which we then pin onto the Host below. */}
|
||||||
);
|
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
|
||||||
}
|
|
||||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
|
||||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
|
||||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
|
||||||
// height depends on the parent while the parent depends on the Host — a
|
|
||||||
// circular dependency that collapses to 0 for any selector nested more than
|
|
||||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
|
||||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
|
||||||
// fills a concrete box.
|
|
||||||
return (
|
|
||||||
<View style={triggerSize ?? { opacity: 0 }}>
|
|
||||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
|
||||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
|
||||||
sizes to the trigger's content rather than to its parent. */}
|
|
||||||
<View
|
<View
|
||||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
style={{ alignSelf: "flex-start" }}
|
||||||
pointerEvents='none'
|
|
||||||
aria-hidden
|
|
||||||
onLayout={handleMeasureTrigger}
|
onLayout={handleMeasureTrigger}
|
||||||
>
|
>
|
||||||
{trigger}
|
{trigger}
|
||||||
</View>
|
</View>
|
||||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
</View>
|
||||||
|
<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
|
||||||
@@ -313,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}
|
||||||
key={`radio-${groupIndex}-${optionIndex}`}
|
selection={
|
||||||
label={option.label}
|
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
|
||||||
systemImage={
|
|
||||||
option.selected ? "checkmark.circle.fill" : "circle"
|
|
||||||
}
|
}
|
||||||
modifiers={
|
onSelectionChange={(index) => {
|
||||||
option.disabled ? [disabled(true)] : undefined
|
const selectedOption = radioOptions[index as number];
|
||||||
}
|
selectedOption?.onPress();
|
||||||
onPress={() => {
|
onOptionSelect?.(selectedOption?.value);
|
||||||
option.onPress();
|
|
||||||
onOptionSelect?.(option.value);
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{radioOptions.map((opt, optionIndex) => (
|
||||||
|
<SwiftUIText
|
||||||
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
|
modifiers={[tag(optionIndex)]}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</SwiftUIText>
|
||||||
))}
|
))}
|
||||||
</Menu>,
|
</Picker>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Render radio options as direct buttons
|
// Render radio options as direct buttons
|
||||||
@@ -428,14 +411,8 @@ const PlatformDropdownComponent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
onPress={handlePress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
<View style={isDisabled ? { opacity: 0.5 } : undefined}>
|
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
MediaPlayerState,
|
MediaStreamType,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useCastDevice,
|
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
@@ -33,8 +32,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { loadCastMedia } from "@/utils/casting/castLoad";
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { chromecast } from "../utils/profiles/chromecast";
|
||||||
|
import { chromecasth265 } from "../utils/profiles/chromecasth265";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import type { SelectedOptions } from "./ItemContent";
|
||||||
@@ -56,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
const { showModal, hideModal } = useGlobalModal();
|
||||||
@@ -109,11 +111,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = [
|
const options = ["Chromecast", "Device", "Cancel"];
|
||||||
t("casting_player.chromecast"),
|
|
||||||
t("casting_player.device"),
|
|
||||||
t("casting_player.cancel"),
|
|
||||||
];
|
|
||||||
const cancelButtonIndex = 2;
|
const cancelButtonIndex = 2;
|
||||||
showActionSheetWithOptions(
|
showActionSheetWithOptions(
|
||||||
{
|
{
|
||||||
@@ -122,14 +120,9 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
async (selectedIndex: number | undefined) => {
|
async (selectedIndex: number | undefined) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
// Compare item IDs AND check if media is actually playing (not stopped/idle)
|
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
|
||||||
const currentContentId = mediaStatus?.mediaInfo?.contentId;
|
|
||||||
const isMediaActive =
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PAUSED ||
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.BUFFERING;
|
|
||||||
const isOpeningCurrentlyPlayingMedia =
|
const isOpeningCurrentlyPlayingMedia =
|
||||||
isMediaActive && currentContentId && currentContentId === item?.Id;
|
currentTitle && currentTitle === item?.Name;
|
||||||
|
|
||||||
switch (selectedIndex) {
|
switch (selectedIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -137,8 +130,30 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (state && state !== PlayServicesState.SUCCESS) {
|
if (state && state !== PlayServicesState.SUCCESS) {
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
} else {
|
} else {
|
||||||
if (!api || !user?.Id || !item?.Id) {
|
// Check if user wants H265 for Chromecast
|
||||||
console.warn("Missing parameters for Chromecast streaming");
|
const enableH265 = settings.enableH265ForChromecast;
|
||||||
|
|
||||||
|
// Validate required parameters before calling getStreamUrl
|
||||||
|
if (!api) {
|
||||||
|
console.warn("API not available for Chromecast streaming");
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.missing_parameters"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user?.Id) {
|
||||||
|
console.warn(
|
||||||
|
"User not authenticated for Chromecast streaming",
|
||||||
|
);
|
||||||
|
Alert.alert(
|
||||||
|
t("player.client_error"),
|
||||||
|
t("player.missing_parameters"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!item?.Id) {
|
||||||
|
console.warn("Item not available for Chromecast streaming");
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.missing_parameters"),
|
t("player.missing_parameters"),
|
||||||
@@ -146,28 +161,24 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPositionMs =
|
// Get a new URL with the Chromecast device profile
|
||||||
(item.UserData?.PlaybackPositionTicks ?? 0) / 10000;
|
try {
|
||||||
|
const data = await getStreamUrl({
|
||||||
const result = await loadCastMedia({
|
|
||||||
client,
|
|
||||||
device: castDevice,
|
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
|
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
profileMode: settings.chromecastProfile,
|
|
||||||
maxBitrateSetting: settings.chromecastMaxBitrate,
|
|
||||||
options: {
|
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
maxBitrate: selectedOptions.bitrate?.value,
|
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? undefined,
|
|
||||||
startPositionMs,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
console.log("URL: ", data?.url, enableH265);
|
||||||
console.error("[PlayButton] cast load failed:", result.error);
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.could_not_create_stream_for_chromecast"),
|
t("player.could_not_create_stream_for_chromecast"),
|
||||||
@@ -175,8 +186,85 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpeningCurrentlyPlayingMedia) {
|
// Calculate start time in seconds from playback position
|
||||||
router.push("/casting-player");
|
const startTimeSeconds =
|
||||||
|
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||||
|
|
||||||
|
// Calculate stream duration in seconds from runtime
|
||||||
|
const streamDurationSeconds = item.RunTimeTicks
|
||||||
|
? item.RunTimeTicks / 10000000
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
client
|
||||||
|
.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentId: item.Id,
|
||||||
|
contentUrl: data?.url,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
streamType: MediaStreamType.BUFFERED,
|
||||||
|
streamDuration: streamDurationSeconds,
|
||||||
|
metadata:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
type: "tvShow",
|
||||||
|
title: item.Name || "",
|
||||||
|
episodeNumber: item.IndexNumber || 0,
|
||||||
|
seasonNumber: item.ParentIndexNumber || 0,
|
||||||
|
seriesTitle: item.SeriesName || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getParentBackdropImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: item.Type === "Movie"
|
||||||
|
? {
|
||||||
|
type: "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "generic",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: startTimeSeconds,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// state is already set when reopening current media, so skip it here.
|
||||||
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CastContext.showExpandedControls();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -192,7 +280,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
client,
|
client,
|
||||||
castDevice,
|
|
||||||
settings,
|
settings,
|
||||||
api,
|
api,
|
||||||
user,
|
user,
|
||||||
@@ -327,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,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Always-mounted host for the Chromecast autoplay watcher. Renders nothing —
|
|
||||||
* it exists only to run `useCastAutoplay` for the app's lifetime, so autoplay
|
|
||||||
* fires regardless of which screen is open.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCastAutoplay } from "@/hooks/useCastAutoplay";
|
|
||||||
|
|
||||||
export function CastAutoplayWatcher() {
|
|
||||||
useCastAutoplay();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Mini Player
|
|
||||||
* Works with all supported casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import {
|
|
||||||
MediaPlayerState,
|
|
||||||
useCastDevice,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import Animated, {
|
|
||||||
SlideInDown,
|
|
||||||
SlideOutDown,
|
|
||||||
useSharedValue,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useCastPlayerItem } from "@/hooks/useCastPlayerItem";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { formatTime, getPosterUrl } from "@/utils/casting/helpers";
|
|
||||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
export const CastingMiniPlayer: React.FC = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
const remoteMediaClient = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const { currentItem } = useCastPlayerItem({ api, user, mediaStatus });
|
|
||||||
|
|
||||||
// Trickplay support - pass currentItem as BaseItemDto or null
|
|
||||||
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
|
|
||||||
currentItem || null,
|
|
||||||
);
|
|
||||||
const [trickplayTime, setTrickplayTime] = useState({
|
|
||||||
hours: 0,
|
|
||||||
minutes: 0,
|
|
||||||
seconds: 0,
|
|
||||||
});
|
|
||||||
const isScrubbing = useRef(false);
|
|
||||||
|
|
||||||
// Slider shared values
|
|
||||||
const sliderProgress = useSharedValue(0);
|
|
||||||
const sliderMin = useSharedValue(0);
|
|
||||||
const sliderMax = useSharedValue(100);
|
|
||||||
|
|
||||||
// Live progress state that updates every second when playing
|
|
||||||
const [liveProgress, setLiveProgress] = useState(
|
|
||||||
mediaStatus?.streamPosition || 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track baseline for elapsed-time computation
|
|
||||||
const baselinePositionRef = useRef(mediaStatus?.streamPosition || 0);
|
|
||||||
const baselineTimestampRef = useRef(Date.now());
|
|
||||||
|
|
||||||
// Sync live progress with mediaStatus and poll every second when playing
|
|
||||||
useEffect(() => {
|
|
||||||
// Resync baseline whenever mediaStatus reports a new position
|
|
||||||
if (mediaStatus?.streamPosition !== undefined) {
|
|
||||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
|
||||||
baselineTimestampRef.current = Date.now();
|
|
||||||
setLiveProgress(mediaStatus.streamPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update based on elapsed real time when playing
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (mediaStatus?.playerState === MediaPlayerState.PLAYING) {
|
|
||||||
const elapsed =
|
|
||||||
((Date.now() - baselineTimestampRef.current) *
|
|
||||||
(mediaStatus.playbackRate || 1)) /
|
|
||||||
1000;
|
|
||||||
setLiveProgress(baselinePositionRef.current + elapsed);
|
|
||||||
} else if (mediaStatus?.streamPosition !== undefined) {
|
|
||||||
// Sync with actual position when paused/buffering
|
|
||||||
baselinePositionRef.current = mediaStatus.streamPosition;
|
|
||||||
baselineTimestampRef.current = Date.now();
|
|
||||||
setLiveProgress(mediaStatus.streamPosition);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [
|
|
||||||
mediaStatus?.playerState,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
mediaStatus?.playbackRate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const progress = liveProgress * 1000; // Convert to ms
|
|
||||||
const duration = (mediaStatus?.mediaInfo?.streamDuration || 0) * 1000;
|
|
||||||
const isPlaying = mediaStatus?.playerState === MediaPlayerState.PLAYING;
|
|
||||||
|
|
||||||
// Update slider max value when duration changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (duration > 0) {
|
|
||||||
sliderMax.value = duration;
|
|
||||||
}
|
|
||||||
}, [duration, sliderMax]);
|
|
||||||
|
|
||||||
// Sync slider progress with live progress (when not scrubbing)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isScrubbing.current && progress >= 0) {
|
|
||||||
sliderProgress.value = progress;
|
|
||||||
}
|
|
||||||
}, [progress, sliderProgress]);
|
|
||||||
|
|
||||||
// For episodes, use series poster; for other content, use item poster
|
|
||||||
const posterUrl = useMemo(() => {
|
|
||||||
if (!api?.basePath || !currentItem) return null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentItem.Type === "Episode" &&
|
|
||||||
currentItem.SeriesId &&
|
|
||||||
currentItem.ParentIndexNumber !== undefined &&
|
|
||||||
currentItem.SeasonId
|
|
||||||
) {
|
|
||||||
// Build series poster URL using SeriesId and series-level image tag
|
|
||||||
const imageTag = currentItem.SeriesPrimaryImageTag || "";
|
|
||||||
const tagParam = imageTag ? `&tag=${imageTag}` : "";
|
|
||||||
return `${api.basePath}/Items/${currentItem.SeriesId}/Images/Primary?fillHeight=120&fillWidth=80&quality=96${tagParam}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-episodes, use item's own poster
|
|
||||||
return getPosterUrl(
|
|
||||||
api.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
80,
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
}, [api?.basePath, currentItem]);
|
|
||||||
|
|
||||||
// Hide mini player when:
|
|
||||||
// - No cast device connected
|
|
||||||
// - No media info (currentItem)
|
|
||||||
// - No media status
|
|
||||||
// - Media is stopped (IDLE state)
|
|
||||||
// - Media is unknown state
|
|
||||||
const playerState = mediaStatus?.playerState;
|
|
||||||
const isMediaStopped = playerState === MediaPlayerState.IDLE;
|
|
||||||
|
|
||||||
if (!castDevice || !currentItem || !mediaStatus || isMediaStopped) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
||||||
const TAB_BAR_HEIGHT = 80; // Standard tab bar height
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
router.push("/casting-player");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTogglePlayPause = () => {
|
|
||||||
if (isPlaying) {
|
|
||||||
remoteMediaClient?.pause()?.catch((error: unknown) => {
|
|
||||||
console.error("[CastingMiniPlayer] Pause error:", error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
remoteMediaClient?.play()?.catch((error: unknown) => {
|
|
||||||
console.error("[CastingMiniPlayer] Play error:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
||||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: "#333",
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Interactive progress slider with trickplay */}
|
|
||||||
<View style={{ paddingHorizontal: 8, paddingTop: 4 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 20 }}
|
|
||||||
progress={sliderProgress}
|
|
||||||
minimumValue={sliderMin}
|
|
||||||
maximumValue={sliderMax}
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: protocolColor,
|
|
||||||
bubbleBackgroundColor: protocolColor,
|
|
||||||
bubbleTextColor: "#fff",
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
isScrubbing.current = true;
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
// Calculate trickplay preview
|
|
||||||
const progressInTicks = msToTicks(value);
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
|
|
||||||
// Update time display for trickplay bubble
|
|
||||||
const progressInSeconds = Math.floor(
|
|
||||||
ticksToSeconds(progressInTicks),
|
|
||||||
);
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTrickplayTime({ hours, minutes, seconds });
|
|
||||||
}}
|
|
||||||
onSlidingComplete={(value) => {
|
|
||||||
isScrubbing.current = false;
|
|
||||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
|
||||||
const positionSeconds = value / 1000;
|
|
||||||
if (remoteMediaClient && duration > 0) {
|
|
||||||
remoteMediaClient
|
|
||||||
.seek({ position: positionSeconds })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Mini Player] Seek error:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderBubble={() => (
|
|
||||||
<CastTrickplayBubble
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
trickplayTime={trickplayTime}
|
|
||||||
tileWidth={190}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
bubbleMaxWidth={190}
|
|
||||||
bubbleWidth={190}
|
|
||||||
bubbleTranslateY={-20}
|
|
||||||
sliderHeight={3}
|
|
||||||
thumbWidth={14}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 5, right: 5 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable onPress={handlePress}>
|
|
||||||
{/* Content */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 12,
|
|
||||||
paddingTop: 6,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Poster */}
|
|
||||||
{posterUrl && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentItem.Name}
|
|
||||||
</Text>
|
|
||||||
{currentItem.SeriesName && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentItem.SeriesName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={12} color={protocolColor} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: protocolColor,
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{castDevice.friendlyName || "Chromecast"}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTime(progress)} / {formatTime(duration)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Stop button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
|
||||||
console.error("[CastingMiniPlayer] Stop error:", error);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
style={{ padding: 8 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='stop' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Play/Pause button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleTogglePlayPause();
|
|
||||||
}}
|
|
||||||
style={{ padding: 8 }}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={28}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Episode Controls
|
|
||||||
* Fixed control row: episode list, previous, next, stop.
|
|
||||||
* Episode-specific buttons (list / previous / next) are conditional;
|
|
||||||
* Stop is always rendered so movies still get a Stop button.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { ImperativeRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface CastPlayerEpisodeControlsProps {
|
|
||||||
/** Bottom safe-area inset, used to offset the fixed control row. */
|
|
||||||
insetBottom: number;
|
|
||||||
/** Id of the currently playing episode. */
|
|
||||||
currentItemId: BaseItemDto["Id"];
|
|
||||||
/** Full episode list for the series. */
|
|
||||||
episodes: BaseItemDto[];
|
|
||||||
/** Next episode in the list, or null if none. */
|
|
||||||
nextEpisode: BaseItemDto | null;
|
|
||||||
/** Remote media client, or null when no session. */
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
/** Open the episode list modal. */
|
|
||||||
onPressEpisodes: () => void;
|
|
||||||
/** Whether the current item exposes chapter markers. */
|
|
||||||
hasChapters: boolean;
|
|
||||||
/** Open the chapter list modal. */
|
|
||||||
onPressChapters: () => void;
|
|
||||||
/** Load a different episode on the Chromecast. */
|
|
||||||
loadEpisode: (episode: BaseItemDto) => Promise<void>;
|
|
||||||
/** Expo Router instance for navigation on stop. */
|
|
||||||
router: ImperativeRouter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerEpisodeControls({
|
|
||||||
insetBottom,
|
|
||||||
currentItemId,
|
|
||||||
episodes,
|
|
||||||
nextEpisode,
|
|
||||||
remoteMediaClient,
|
|
||||||
onPressEpisodes,
|
|
||||||
hasChapters,
|
|
||||||
onPressChapters,
|
|
||||||
loadEpisode,
|
|
||||||
router,
|
|
||||||
}: CastPlayerEpisodeControlsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const hasEpisodeList = episodes.length > 0;
|
|
||||||
const hasPrevious = episodes.findIndex((ep) => ep.Id === currentItemId) > 0;
|
|
||||||
const hasNext = nextEpisode != null;
|
|
||||||
|
|
||||||
// Count of buttons actually rendered (Stop is always rendered).
|
|
||||||
const buttonCount =
|
|
||||||
1 +
|
|
||||||
(hasEpisodeList ? 1 : 0) +
|
|
||||||
(hasChapters ? 1 : 0) +
|
|
||||||
(hasPrevious ? 1 : 0) +
|
|
||||||
(hasNext ? 1 : 0);
|
|
||||||
|
|
||||||
// When Stop is the only button (movies), render it full-width with a label.
|
|
||||||
const isLoneStop = buttonCount === 1;
|
|
||||||
|
|
||||||
// Each button stretches evenly only when the row holds more than one;
|
|
||||||
// a lone Stop button keeps its intrinsic size and stays centered.
|
|
||||||
const buttonStyle = {
|
|
||||||
...(buttonCount > 1 ? { flex: 1 } : {}),
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
flexDirection: "row" as const,
|
|
||||||
justifyContent: "center" as const,
|
|
||||||
alignItems: "center" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: insetBottom + 200,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 16,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Episodes button - only rendered when an episode list exists (not for movies) */}
|
|
||||||
{hasEpisodeList && (
|
|
||||||
<Pressable onPress={onPressEpisodes} style={buttonStyle}>
|
|
||||||
<Ionicons name='list' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chapter list button - rendered for both episodes and movies when chapters exist */}
|
|
||||||
{hasChapters && (
|
|
||||||
<Pressable onPress={onPressChapters} style={buttonStyle}>
|
|
||||||
<Ionicons name='bookmarks' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Previous episode button - only rendered when a previous episode exists */}
|
|
||||||
{hasPrevious && (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
const currentIndex = episodes.findIndex(
|
|
||||||
(ep) => ep.Id === currentItemId,
|
|
||||||
);
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
await loadEpisode(episodes[currentIndex - 1]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={buttonStyle}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-back' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Next episode button - only rendered when a next episode exists */}
|
|
||||||
{hasNext && (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
if (nextEpisode) {
|
|
||||||
await loadEpisode(nextEpisode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={buttonStyle}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-forward' size={22} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stop playback button - always rendered; stops media but stays connected to Chromecast */}
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
try {
|
|
||||||
// Stop the current media playback (don't disconnect from Chromecast)
|
|
||||||
if (remoteMediaClient) {
|
|
||||||
await remoteMediaClient.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate back/close the player (mini player will disappear since no media is playing)
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Error stopping playback:", error);
|
|
||||||
// Navigate anyway
|
|
||||||
if (router.canGoBack()) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.replace("/(auth)/(tabs)/(home)/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={[buttonStyle, isLoneStop && { flex: 1, gap: 8 }]}
|
|
||||||
>
|
|
||||||
<Ionicons name='stop-circle' size={22} color='white' />
|
|
||||||
{isLoneStop && (
|
|
||||||
<Text style={{ color: "white", fontSize: 15, fontWeight: "600" }}>
|
|
||||||
{t("casting_player.stop")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Header
|
|
||||||
* Fixed top bar: dismiss button, connection indicator, settings button.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface CastPlayerHeaderProps {
|
|
||||||
/** Top safe-area inset, used to offset the fixed header. */
|
|
||||||
insetTop: number;
|
|
||||||
/** Streamyfin protocol accent color. */
|
|
||||||
protocolColor: string;
|
|
||||||
/** Friendly name of the connected cast device, or null. */
|
|
||||||
currentDevice: string | null;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
/** Dismiss the casting player modal. */
|
|
||||||
onDismiss: () => void;
|
|
||||||
/** Open the device sheet (connection indicator press). */
|
|
||||||
onPressConnectionIndicator: () => void;
|
|
||||||
/** Open the settings menu. */
|
|
||||||
onPressSettings: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerHeader({
|
|
||||||
insetTop,
|
|
||||||
protocolColor,
|
|
||||||
currentDevice,
|
|
||||||
t,
|
|
||||||
onDismiss,
|
|
||||||
onPressConnectionIndicator,
|
|
||||||
onPressSettings,
|
|
||||||
}: CastPlayerHeaderProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insetTop + 8,
|
|
||||||
left: 20,
|
|
||||||
right: 20,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable onPress={onDismiss} style={{ padding: 8, marginLeft: -8 }}>
|
|
||||||
<Ionicons name='chevron-down' size={32} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Connection indicator */}
|
|
||||||
<Pressable
|
|
||||||
onPress={onPressConnectionIndicator}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderRadius: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: protocolColor,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "500",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentDevice || t("casting_player.unknown_device")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={onPressSettings}
|
|
||||||
style={{ padding: 8, marginRight: -8 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='settings-outline' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Poster
|
|
||||||
* Poster image with empty-state fallback, skip intro/credits bar, and buffering overlay.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { ActivityIndicator, Pressable, View } from "react-native";
|
|
||||||
import {
|
|
||||||
MediaPlayerState,
|
|
||||||
type MediaStatus,
|
|
||||||
type RemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import type { useChromecastSegments } from "@/components/chromecast/hooks/useChromecastSegments";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
type ChromecastSegments = ReturnType<typeof useChromecastSegments>;
|
|
||||||
|
|
||||||
interface CastPlayerPosterProps {
|
|
||||||
/** Poster image URL, or null when unavailable. */
|
|
||||||
posterUrl: string | null;
|
|
||||||
/** Whether the cast media is currently buffering. */
|
|
||||||
isBuffering: boolean;
|
|
||||||
/** The current playback segment (intro/credits/etc.), or null. */
|
|
||||||
currentSegment: ChromecastSegments["currentSegment"];
|
|
||||||
/** Skip the intro segment. */
|
|
||||||
skipIntro: ChromecastSegments["skipIntro"];
|
|
||||||
/** Skip the credits segment. */
|
|
||||||
skipCredits: ChromecastSegments["skipCredits"];
|
|
||||||
/** Skip the current generic segment. */
|
|
||||||
skipSegment: ChromecastSegments["skipSegment"];
|
|
||||||
/** The remote media client, or null when no session. */
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
/** Raw Chromecast media status. */
|
|
||||||
mediaStatus: MediaStatus | null;
|
|
||||||
/** Theme accent color. */
|
|
||||||
protocolColor: string;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerPoster({
|
|
||||||
posterUrl,
|
|
||||||
isBuffering,
|
|
||||||
currentSegment,
|
|
||||||
skipIntro,
|
|
||||||
skipCredits,
|
|
||||||
skipSegment,
|
|
||||||
remoteMediaClient,
|
|
||||||
mediaStatus,
|
|
||||||
protocolColor,
|
|
||||||
t,
|
|
||||||
}: CastPlayerPosterProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 280,
|
|
||||||
height: 420,
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{posterUrl ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='film-outline' size={64} color='#333' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Skip intro/credits bar at bottom of poster */}
|
|
||||||
{currentSegment && (
|
|
||||||
<Pressable
|
|
||||||
onPress={async () => {
|
|
||||||
if (!remoteMediaClient) return;
|
|
||||||
try {
|
|
||||||
const seekFn = async (positionMs: number) => {
|
|
||||||
if (
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PLAYING ||
|
|
||||||
mediaStatus?.playerState === MediaPlayerState.PAUSED
|
|
||||||
) {
|
|
||||||
await remoteMediaClient.seek({
|
|
||||||
position: positionMs / 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (currentSegment.type === "intro") {
|
|
||||||
await skipIntro(seekFn);
|
|
||||||
} else if (currentSegment.type === "credits") {
|
|
||||||
await skipCredits(seekFn);
|
|
||||||
} else {
|
|
||||||
await skipSegment(seekFn);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Casting Player] Skip error:", error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-skip-forward' size={18} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
`player.skip_${currentSegment.type === "credits" ? "outro" : currentSegment.type}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buffering overlay */}
|
|
||||||
{isBuffering && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0,0,0,0.7)",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ActivityIndicator size='large' color={protocolColor} />
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 16,
|
|
||||||
marginTop: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("casting_player.buffering")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Progress Bar
|
|
||||||
* Progress slider with trickplay preview bubble and current/end time display.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { Text, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import type { RemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import type { SharedValue } from "react-native-reanimated";
|
|
||||||
import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";
|
|
||||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
|
||||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { calculateEndingTime, formatTime } from "@/utils/casting/helpers";
|
|
||||||
import { chapterMarkers } from "@/utils/chapters";
|
|
||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
||||||
|
|
||||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
|
||||||
|
|
||||||
interface CastPlayerProgressBarProps {
|
|
||||||
/** Shared value tracking the slider progress, in milliseconds. */
|
|
||||||
sliderProgress: SharedValue<number>;
|
|
||||||
/** Shared value for the slider minimum, in milliseconds. */
|
|
||||||
sliderMin: SharedValue<number>;
|
|
||||||
/** Shared value for the slider maximum, in milliseconds. */
|
|
||||||
sliderMax: SharedValue<number>;
|
|
||||||
/** Mutable ref flag set true while the user is scrubbing. */
|
|
||||||
isScrubbing: { current: boolean };
|
|
||||||
/** Trickplay time display state for the bubble. */
|
|
||||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
|
||||||
/** Updates the trickplay time display state. */
|
|
||||||
setTrickplayTime: (time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
}) => void;
|
|
||||||
/** Current trickplay image URL/coordinates, or null. */
|
|
||||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
|
||||||
/** Computes the trickplay URL for a given progress in ticks. */
|
|
||||||
calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"];
|
|
||||||
/** Parsed trickplay metadata, or null. */
|
|
||||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
|
||||||
/** Current playback progress, in seconds. */
|
|
||||||
progress: number;
|
|
||||||
/** Total media duration, in seconds. */
|
|
||||||
duration: number;
|
|
||||||
/** Remote media client, or null when no session. */
|
|
||||||
remoteMediaClient: RemoteMediaClient | null;
|
|
||||||
/** Theme color used for the slider track and bubbles. */
|
|
||||||
protocolColor: string;
|
|
||||||
/** Chapter markers for the current item, or null/undefined if none. */
|
|
||||||
chapters?: ChapterInfo[] | null;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerProgressBar({
|
|
||||||
sliderProgress,
|
|
||||||
sliderMin,
|
|
||||||
sliderMax,
|
|
||||||
isScrubbing,
|
|
||||||
trickplayTime,
|
|
||||||
setTrickplayTime,
|
|
||||||
trickPlayUrl,
|
|
||||||
calculateTrickplayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
remoteMediaClient,
|
|
||||||
protocolColor,
|
|
||||||
chapters,
|
|
||||||
t,
|
|
||||||
}: CastPlayerProgressBarProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Progress slider with trickplay preview */}
|
|
||||||
<View style={{ marginTop: 8, height: 40 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={sliderProgress}
|
|
||||||
minimumValue={sliderMin}
|
|
||||||
maximumValue={sliderMax}
|
|
||||||
theme={{
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: protocolColor,
|
|
||||||
bubbleBackgroundColor: protocolColor,
|
|
||||||
bubbleTextColor: "#fff",
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
isScrubbing.current = true;
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
// Calculate trickplay preview
|
|
||||||
const progressInTicks = msToTicks(value);
|
|
||||||
calculateTrickplayUrl(progressInTicks);
|
|
||||||
|
|
||||||
// Update time display for trickplay bubble
|
|
||||||
const progressInSeconds = Math.floor(
|
|
||||||
ticksToSeconds(progressInTicks),
|
|
||||||
);
|
|
||||||
const hours = Math.floor(progressInSeconds / 3600);
|
|
||||||
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
||||||
const seconds = progressInSeconds % 60;
|
|
||||||
setTrickplayTime({ hours, minutes, seconds });
|
|
||||||
}}
|
|
||||||
onSlidingComplete={(value) => {
|
|
||||||
isScrubbing.current = false;
|
|
||||||
// Seek to the position (value is in milliseconds, convert to seconds)
|
|
||||||
const positionSeconds = value / 1000;
|
|
||||||
if (remoteMediaClient && duration > 0) {
|
|
||||||
remoteMediaClient
|
|
||||||
.seek({ position: positionSeconds })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[Casting Player] Seek error:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
renderBubble={() => (
|
|
||||||
<CastTrickplayBubble
|
|
||||||
trickPlayUrl={trickPlayUrl}
|
|
||||||
trickplayInfo={trickplayInfo}
|
|
||||||
trickplayTime={trickplayTime}
|
|
||||||
tileWidth={220}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
bubbleMaxWidth={220}
|
|
||||||
bubbleWidth={220}
|
|
||||||
bubbleTranslateY={-20}
|
|
||||||
sliderHeight={6}
|
|
||||||
thumbWidth={16}
|
|
||||||
panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }}
|
|
||||||
/>
|
|
||||||
<ChapterTicks
|
|
||||||
markers={chapterMarkers(chapters, duration * 1000)}
|
|
||||||
height={4}
|
|
||||||
color='#cccccc'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Time display */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
||||||
{formatTime(progress * 1000)}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
||||||
{t("casting_player.ending_at", {
|
|
||||||
time: calculateEndingTime(progress * 1000, duration * 1000),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#999", fontSize: 13 }}>
|
|
||||||
{formatTime(duration * 1000)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Title Area
|
|
||||||
* Fixed title bar: item title and optional grey episode/season info.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { truncateTitle } from "@/utils/casting/helpers";
|
|
||||||
|
|
||||||
interface CastPlayerTitleProps {
|
|
||||||
/** Top safe-area inset, used to offset the fixed title area. */
|
|
||||||
insetTop: number;
|
|
||||||
/** The currently playing item. */
|
|
||||||
currentItem: BaseItemDto;
|
|
||||||
/** Translation function. */
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerTitle({
|
|
||||||
insetTop,
|
|
||||||
currentItem,
|
|
||||||
t,
|
|
||||||
}: CastPlayerTitleProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: insetTop + 50,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 95,
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
||||||
paddingVertical: 16,
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: "700",
|
|
||||||
textAlign: "center",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{truncateTitle(currentItem.Name || t("casting_player.unknown"), 50)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Grey episode/season info */}
|
|
||||||
{currentItem.Type === "Episode" &&
|
|
||||||
currentItem.ParentIndexNumber !== undefined &&
|
|
||||||
currentItem.IndexNumber !== undefined && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 15,
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("casting_player.season_episode_format", {
|
|
||||||
season: currentItem.ParentIndexNumber,
|
|
||||||
episode: currentItem.IndexNumber,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Casting Player Transport Controls
|
|
||||||
* Playback transport row: rewind, play/pause, forward.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface CastPlayerTransportControlsProps {
|
|
||||||
/** Whether playback is currently playing. */
|
|
||||||
isPlaying: boolean;
|
|
||||||
/** Toggle play/pause on the Chromecast. */
|
|
||||||
togglePlayPause: () => Promise<void>;
|
|
||||||
/** Skip backward by the given number of seconds. */
|
|
||||||
skipBackward: (seconds: number) => Promise<void>;
|
|
||||||
/** Skip forward by the given number of seconds. */
|
|
||||||
skipForward: (seconds: number) => Promise<void>;
|
|
||||||
/** Configured rewind skip time in seconds, shown on the rewind button. */
|
|
||||||
rewindSkipTime: number | null | undefined;
|
|
||||||
/** Configured forward skip time in seconds, shown on the forward button. */
|
|
||||||
forwardSkipTime: number | null | undefined;
|
|
||||||
/** Accent color used for the play/pause button background. */
|
|
||||||
protocolColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastPlayerTransportControls({
|
|
||||||
isPlaying,
|
|
||||||
togglePlayPause,
|
|
||||||
skipBackward,
|
|
||||||
skipForward,
|
|
||||||
rewindSkipTime,
|
|
||||||
forwardSkipTime,
|
|
||||||
protocolColor,
|
|
||||||
}: CastPlayerTransportControlsProps) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 32,
|
|
||||||
marginBottom: 24,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Rewind (use settings) */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => skipBackward(rewindSkipTime ?? 10)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='refresh-outline'
|
|
||||||
size={48}
|
|
||||||
color='white'
|
|
||||||
style={{ transform: [{ scaleY: -1 }, { rotate: "180deg" }] }}
|
|
||||||
/>
|
|
||||||
{rewindSkipTime != null && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rewindSkipTime}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Play/Pause */}
|
|
||||||
<Pressable
|
|
||||||
onPress={togglePlayPause}
|
|
||||||
style={{
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
borderRadius: 36,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={36}
|
|
||||||
color='white'
|
|
||||||
style={{ marginLeft: isPlaying ? 0 : 4 }}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Forward (use settings) */}
|
|
||||||
<Pressable
|
|
||||||
onPress={() => skipForward(forwardSkipTime ?? 10)}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='refresh-outline' size={48} color='white' />
|
|
||||||
{forwardSkipTime != null && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
color: "white",
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: "bold",
|
|
||||||
bottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{forwardSkipTime}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared scrub-preview bubble for the casting progress bars.
|
|
||||||
*
|
|
||||||
* The slider (`react-native-awesome-slider`) sizes, centres and clamps this
|
|
||||||
* bubble on the thumb via its `bubbleMaxWidth` / `bubbleWidth` props. This
|
|
||||||
* component therefore does NO horizontal positioning — it only anchors itself
|
|
||||||
* vertically (`bottom: 0`, growing upward) so it sits above the progress bar.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import type { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { formatTrickplayTime } from "@/utils/casting/helpers";
|
|
||||||
|
|
||||||
type TrickplayReturn = ReturnType<typeof useTrickplay>;
|
|
||||||
|
|
||||||
interface CastTrickplayBubbleProps {
|
|
||||||
/** Current trickplay image URL/coordinates, or null. */
|
|
||||||
trickPlayUrl: TrickplayReturn["trickPlayUrl"];
|
|
||||||
/** Parsed trickplay metadata, or null. */
|
|
||||||
trickplayInfo: TrickplayReturn["trickplayInfo"];
|
|
||||||
/** Scrub time to display. */
|
|
||||||
trickplayTime: { hours: number; minutes: number; seconds: number };
|
|
||||||
/** Trickplay tile width in px (220 main player, 140 mini-player). */
|
|
||||||
tileWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CastTrickplayBubble({
|
|
||||||
trickPlayUrl,
|
|
||||||
trickplayInfo,
|
|
||||||
trickplayTime,
|
|
||||||
tileWidth,
|
|
||||||
}: CastTrickplayBubbleProps) {
|
|
||||||
const timeText = (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTrickplayTime(trickplayTime)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Anchored to the bottom of the slider-positioned container, growing upward,
|
|
||||||
// and filling the container width (left/right: 0) so it stays centred on the
|
|
||||||
// thumb. No horizontal maths here — the slider owns horizontal placement.
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{timeText}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{timeText}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
borderRadius: 8,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
style={{
|
|
||||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
|
||||||
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,199 +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 {
|
|
||||||
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 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}>
|
|
||||||
<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",
|
|
||||||
backgroundColor: "rgba(0,0,0,0.6)",
|
|
||||||
},
|
|
||||||
sheet: {
|
|
||||||
backgroundColor: Colors.background,
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
maxHeight: "70%",
|
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
|
||||||
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);
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Connection Menu
|
|
||||||
* Shows device info, volume control, and disconnect option
|
|
||||||
* Simple menu for when connected but not actively controlling playback
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Modal, Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import { useCastDevice, useCastSession } from "react-native-google-cast";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface ChromecastConnectionMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onDisconnect?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastConnectionMenu: React.FC<
|
|
||||||
ChromecastConnectionMenuProps
|
|
||||||
> = ({ visible, onClose, onDisconnect }) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const castSession = useCastSession();
|
|
||||||
|
|
||||||
// Volume state - use refs to avoid triggering re-renders during sliding
|
|
||||||
const [displayVolume, setDisplayVolume] = useState(50);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const isMutedRef = useRef(false);
|
|
||||||
const volumeValue = useSharedValue(50);
|
|
||||||
const minimumValue = useSharedValue(0);
|
|
||||||
const maximumValue = useSharedValue(100);
|
|
||||||
const isSliding = useRef(false);
|
|
||||||
const lastSetVolume = useRef(50);
|
|
||||||
|
|
||||||
const protocolColor = "#a855f7";
|
|
||||||
|
|
||||||
// Get initial volume and mute state when menu opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || !castSession) return;
|
|
||||||
|
|
||||||
// Get initial states
|
|
||||||
const fetchInitialState = async () => {
|
|
||||||
try {
|
|
||||||
const vol = await castSession.getVolume();
|
|
||||||
if (vol !== undefined) {
|
|
||||||
const percent = Math.round(vol * 100);
|
|
||||||
setDisplayVolume(percent);
|
|
||||||
volumeValue.value = percent;
|
|
||||||
lastSetVolume.current = percent;
|
|
||||||
}
|
|
||||||
const muted = await castSession.isMute();
|
|
||||||
isMutedRef.current = muted;
|
|
||||||
setIsMuted(muted);
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchInitialState();
|
|
||||||
|
|
||||||
// Poll for external volume changes (physical buttons) - only when not sliding
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (isSliding.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const vol = await castSession.getVolume();
|
|
||||||
if (vol !== undefined) {
|
|
||||||
const percent = Math.round(vol * 100);
|
|
||||||
// Only update if external change detected (not our own change)
|
|
||||||
if (Math.abs(percent - lastSetVolume.current) > 2) {
|
|
||||||
setDisplayVolume(percent);
|
|
||||||
volumeValue.value = percent;
|
|
||||||
lastSetVolume.current = percent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const muted = await castSession.isMute();
|
|
||||||
if (muted !== isMutedRef.current) {
|
|
||||||
isMutedRef.current = muted;
|
|
||||||
setIsMuted(muted);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
}, 1000); // Poll less frequently
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [visible, castSession, volumeValue]);
|
|
||||||
|
|
||||||
// Volume change during sliding - update display only, don't call API
|
|
||||||
const handleVolumeChange = useCallback((value: number) => {
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
setDisplayVolume(rounded);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Volume change complete - call API
|
|
||||||
const handleVolumeComplete = useCallback(
|
|
||||||
async (value: number) => {
|
|
||||||
isSliding.current = false;
|
|
||||||
const rounded = Math.round(value);
|
|
||||||
setDisplayVolume(rounded);
|
|
||||||
lastSetVolume.current = rounded;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (castSession) {
|
|
||||||
await castSession.setVolume(rounded / 100);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Connection Menu] Volume error:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[castSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Toggle mute
|
|
||||||
const handleToggleMute = useCallback(async () => {
|
|
||||||
if (!castSession) return;
|
|
||||||
try {
|
|
||||||
const newMute = !isMuted;
|
|
||||||
await castSession.setMute(newMute);
|
|
||||||
isMutedRef.current = newMute;
|
|
||||||
setIsMuted(newMute);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Connection Menu] Mute error:", error);
|
|
||||||
}
|
|
||||||
}, [castSession, isMuted]);
|
|
||||||
|
|
||||||
// Disconnect
|
|
||||||
const handleDisconnect = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (onDisconnect) {
|
|
||||||
await onDisconnect();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Connection Menu] Disconnect error:", error);
|
|
||||||
} finally {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [onDisconnect, onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 20,
|
|
||||||
borderTopRightRadius: 20,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header with device name */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={20} color='white' />
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{castDevice?.friendlyName || t("casting_player.chromecast")}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: protocolColor, fontSize: 12 }}>
|
|
||||||
{t("casting_player.connected")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Volume Control */}
|
|
||||||
<View style={{ padding: 16 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
|
||||||
{t("casting_player.volume")}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 14 }}>
|
|
||||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: isMuted ? protocolColor : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isMuted ? "volume-mute" : "volume-low"}
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "white" : "#999"}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={volumeValue}
|
|
||||||
minimumValue={minimumValue}
|
|
||||||
maximumValue={maximumValue}
|
|
||||||
theme={{
|
|
||||||
disableMinTrackTintColor: "#333",
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: isMuted ? "#666" : protocolColor,
|
|
||||||
bubbleBackgroundColor: protocolColor,
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
isSliding.current = true;
|
|
||||||
}}
|
|
||||||
onValueChange={async (value) => {
|
|
||||||
volumeValue.value = value;
|
|
||||||
handleVolumeChange(value);
|
|
||||||
// Unmute when adjusting volume - use ref to avoid
|
|
||||||
// stale closure and prevent repeated async calls
|
|
||||||
if (isMutedRef.current) {
|
|
||||||
isMutedRef.current = false;
|
|
||||||
setIsMuted(false);
|
|
||||||
try {
|
|
||||||
await castSession?.setMute(false);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(
|
|
||||||
"[ChromecastConnectionMenu] Failed to unmute:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
isMutedRef.current = true;
|
|
||||||
setIsMuted(true); // Rollback on failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSlidingComplete={handleVolumeComplete}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name='volume-high'
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "#666" : "#999"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Disconnect button */}
|
|
||||||
<View style={{ paddingHorizontal: 16 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleDisconnect}
|
|
||||||
style={{
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
padding: 14,
|
|
||||||
borderRadius: 8,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='power' size={20} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 14, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
{t("casting_player.disconnect")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Device Info Sheet
|
|
||||||
* Shows device details, volume control, and disconnect option
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Modal, Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
|
||||||
import { useCastSession } from "react-native-google-cast";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface ChromecastDeviceSheetProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
device: { friendlyName?: string } | null;
|
|
||||||
onDisconnect: () => Promise<void>;
|
|
||||||
volume?: number;
|
|
||||||
onVolumeChange?: (volume: number) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
device,
|
|
||||||
onDisconnect,
|
|
||||||
volume = 0.5,
|
|
||||||
onVolumeChange,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
const [displayVolume, setDisplayVolume] = useState(Math.round(volume * 100));
|
|
||||||
const volumeValue = useSharedValue(volume * 100);
|
|
||||||
const minimumValue = useSharedValue(0);
|
|
||||||
const maximumValue = useSharedValue(100);
|
|
||||||
const castSession = useCastSession();
|
|
||||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
const isSliding = useRef(false);
|
|
||||||
const lastSetVolume = useRef(Math.round(volume * 100));
|
|
||||||
|
|
||||||
// Sync volume slider with prop changes (updates from physical buttons)
|
|
||||||
// Skip updates while user is actively sliding to avoid overwriting drag
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSliding.current) return;
|
|
||||||
volumeValue.value = volume * 100;
|
|
||||||
setDisplayVolume(Math.round(volume * 100));
|
|
||||||
}, [volume, volumeValue]);
|
|
||||||
|
|
||||||
// Poll for volume and mute updates when sheet is visible to catch physical button changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible || !castSession) return;
|
|
||||||
|
|
||||||
// Get initial mute state
|
|
||||||
castSession
|
|
||||||
.isMute()
|
|
||||||
.then(setIsMuted)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
// Poll CastSession for device volume and mute state (only when not sliding)
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
if (isSliding.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deviceVolume = await castSession.getVolume();
|
|
||||||
if (deviceVolume !== undefined) {
|
|
||||||
const volumePercent = Math.round(deviceVolume * 100);
|
|
||||||
// Only update if external change (physical buttons)
|
|
||||||
if (Math.abs(volumePercent - lastSetVolume.current) > 2) {
|
|
||||||
setDisplayVolume(volumePercent);
|
|
||||||
volumeValue.value = volumePercent;
|
|
||||||
lastSetVolume.current = volumePercent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check mute state
|
|
||||||
const muteState = await castSession.isMute();
|
|
||||||
setIsMuted(muteState);
|
|
||||||
} catch {
|
|
||||||
// Ignore errors - device might be disconnected
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [visible, castSession, volumeValue]);
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
try {
|
|
||||||
await onDisconnect();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect:", error);
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeComplete = async (value: number) => {
|
|
||||||
const newVolume = value / 100;
|
|
||||||
setDisplayVolume(Math.round(value));
|
|
||||||
try {
|
|
||||||
// Use CastSession.setVolume for DEVICE volume control
|
|
||||||
// This works even when no media is playing, unlike setStreamVolume
|
|
||||||
if (castSession) {
|
|
||||||
await castSession.setVolume(newVolume);
|
|
||||||
} else if (onVolumeChange) {
|
|
||||||
// Fallback to prop method if session not available
|
|
||||||
await onVolumeChange(newVolume);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Volume] Error setting volume:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Debounced volume update during sliding for smooth live feedback
|
|
||||||
const handleVolumeChange = useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
setDisplayVolume(Math.round(value));
|
|
||||||
|
|
||||||
// Debounce the API call to avoid too many requests
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeDebounceRef.current = setTimeout(async () => {
|
|
||||||
const newVolume = value / 100;
|
|
||||||
try {
|
|
||||||
if (castSession) {
|
|
||||||
await castSession.setVolume(newVolume);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors during sliding
|
|
||||||
}
|
|
||||||
}, 150); // 150ms debounce
|
|
||||||
},
|
|
||||||
[castSession],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Toggle mute state
|
|
||||||
const handleToggleMute = useCallback(async () => {
|
|
||||||
if (!castSession) return;
|
|
||||||
try {
|
|
||||||
const newMuteState = !isMuted;
|
|
||||||
await castSession.setMute(newMuteState);
|
|
||||||
setIsMuted(newMuteState);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Volume] Error toggling mute:", error);
|
|
||||||
}
|
|
||||||
}, [castSession, isMuted]);
|
|
||||||
|
|
||||||
// Cleanup debounce timer on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 18, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{t("casting_player.chromecast")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Device info */}
|
|
||||||
<View style={{ padding: 16 }}>
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
|
||||||
{t("casting_player.device_name")}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
{device?.friendlyName || t("casting_player.unknown_device")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{/* Volume control */}
|
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
|
||||||
{t("casting_player.volume")}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 14 }}>
|
|
||||||
{isMuted ? t("casting_player.muted") : `${displayVolume}%`}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Mute button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: isMuted ? "#a855f7" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isMuted ? "volume-mute" : "volume-low"}
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "white" : "#999"}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={volumeValue}
|
|
||||||
minimumValue={minimumValue}
|
|
||||||
maximumValue={maximumValue}
|
|
||||||
theme={{
|
|
||||||
disableMinTrackTintColor: "#333",
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: isMuted ? "#666" : "#a855f7",
|
|
||||||
bubbleBackgroundColor: "#a855f7",
|
|
||||||
}}
|
|
||||||
onSlidingStart={async () => {
|
|
||||||
isSliding.current = true;
|
|
||||||
// Auto-unmute when user starts adjusting volume
|
|
||||||
if (isMuted && castSession) {
|
|
||||||
setIsMuted(false);
|
|
||||||
try {
|
|
||||||
await castSession.setMute(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Volume] Failed to unmute:", error);
|
|
||||||
setIsMuted(true); // Rollback on failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
volumeValue.value = value;
|
|
||||||
handleVolumeChange(value);
|
|
||||||
}}
|
|
||||||
onSlidingComplete={(value) => {
|
|
||||||
isSliding.current = false;
|
|
||||||
lastSetVolume.current = Math.round(value);
|
|
||||||
handleVolumeComplete(value);
|
|
||||||
}}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name='volume-high'
|
|
||||||
size={20}
|
|
||||||
color={isMuted ? "#666" : "#999"}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Disconnect button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleDisconnect}
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#a855f7",
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
opacity: isDisconnecting ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name='power'
|
|
||||||
size={20}
|
|
||||||
color='white'
|
|
||||||
style={{ marginTop: 2 }}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{isDisconnecting
|
|
||||||
? t("casting_player.disconnecting")
|
|
||||||
: t("casting_player.stop_casting")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
/**
|
|
||||||
* Episode List for Chromecast Player
|
|
||||||
* Displays list of episodes for TV shows with thumbnails
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FlatList, Modal, Pressable, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { truncateTitle } from "@/utils/casting/helpers";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
interface ChromecastEpisodeListProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
episodes: BaseItemDto[];
|
|
||||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
|
||||||
api: Api | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
currentItem,
|
|
||||||
episodes,
|
|
||||||
onSelectEpisode,
|
|
||||||
api,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
|
||||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
|
||||||
const scrollRetryCountRef = useRef(0);
|
|
||||||
const scrollRetryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const MAX_SCROLL_RETRIES = 3;
|
|
||||||
|
|
||||||
// Cleanup pending retry timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
scrollRetryTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
scrollRetryCountRef.current = 0;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Get unique seasons from episodes
|
|
||||||
const seasons = useMemo(() => {
|
|
||||||
const seasonSet = new Set<number>();
|
|
||||||
for (const ep of episodes) {
|
|
||||||
if (ep.ParentIndexNumber !== undefined && ep.ParentIndexNumber !== null) {
|
|
||||||
seasonSet.add(ep.ParentIndexNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(seasonSet).sort((a, b) => a - b);
|
|
||||||
}, [episodes]);
|
|
||||||
|
|
||||||
// Filter episodes by selected season and exclude virtual episodes
|
|
||||||
const filteredEpisodes = useMemo(() => {
|
|
||||||
let eps = episodes;
|
|
||||||
|
|
||||||
// Filter by season if selected
|
|
||||||
if (selectedSeason !== null) {
|
|
||||||
eps = eps.filter((ep) => ep.ParentIndexNumber === selectedSeason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out virtual episodes (episodes without actual video files)
|
|
||||||
// LocationType === "Virtual" means the episode doesn't have a media file
|
|
||||||
eps = eps.filter((ep) => ep.LocationType !== "Virtual");
|
|
||||||
|
|
||||||
return eps;
|
|
||||||
}, [episodes, selectedSeason]);
|
|
||||||
|
|
||||||
// Set initial season to current episode's season
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentItem?.ParentIndexNumber !== undefined) {
|
|
||||||
setSelectedSeason(currentItem.ParentIndexNumber);
|
|
||||||
}
|
|
||||||
}, [currentItem]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset retry counter when visibility or data changes
|
|
||||||
scrollRetryCountRef.current = 0;
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible && currentItem && filteredEpisodes.length > 0) {
|
|
||||||
const currentIndex = filteredEpisodes.findIndex(
|
|
||||||
(ep) => ep.Id === currentItem.Id,
|
|
||||||
);
|
|
||||||
if (currentIndex !== -1 && flatListRef.current) {
|
|
||||||
// Delay to ensure FlatList is rendered
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: currentIndex,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5, // Center the item
|
|
||||||
});
|
|
||||||
}, 300);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [visible, currentItem, filteredEpisodes]);
|
|
||||||
|
|
||||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
|
||||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSelectEpisode(item);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
padding: 12,
|
|
||||||
// Translucent (not solid) purple so the dark base shows through and
|
|
||||||
// the row's text — incl. the purple S:E label — stays readable. The
|
|
||||||
// play-circle icon also marks the current episode.
|
|
||||||
backgroundColor: isCurrentEpisode
|
|
||||||
? "rgba(168, 85, 247, 0.25)"
|
|
||||||
: "transparent",
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 68,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const imageUrl =
|
|
||||||
api && item.Id ? getPrimaryImageUrl({ api, item }) : null;
|
|
||||||
if (imageUrl) {
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
source={{ uri: imageUrl }}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='film-outline' size={32} color='#333' />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode info */}
|
|
||||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.IndexNumber != null ? `${item.IndexNumber}. ` : ""}
|
|
||||||
{truncateTitle(item.Name || t("casting_player.unknown"), 30)}
|
|
||||||
</Text>
|
|
||||||
{item.Overview && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 12,
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
|
||||||
{item.ParentIndexNumber !== undefined &&
|
|
||||||
item.IndexNumber !== undefined && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#a855f7", fontSize: 11, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
S{String(item.ParentIndexNumber).padStart(2, "0")}:E
|
|
||||||
{String(item.IndexNumber).padStart(2, "0")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.ProductionYear && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.RunTimeTicks && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{Math.round(item.RunTimeTicks / 600000000)}{" "}
|
|
||||||
{t("casting_player.minutes_short")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isCurrentEpisode && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-circle' size={24} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: insets.top,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: seasons.length > 1 ? 12 : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
{t("casting_player.episodes")}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Season selector */}
|
|
||||||
{seasons.length > 1 && (
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
contentContainerStyle={{ gap: 8 }}
|
|
||||||
>
|
|
||||||
{seasons.map((season) => (
|
|
||||||
<Pressable
|
|
||||||
key={season}
|
|
||||||
onPress={() => setSelectedSeason(season)}
|
|
||||||
style={{
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSeason === season ? "#a855f7" : "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: selectedSeason === season ? "600" : "400",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("casting_player.season", { number: season })}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<FlatList
|
|
||||||
ref={flatListRef}
|
|
||||||
data={filteredEpisodes}
|
|
||||||
renderItem={renderEpisode}
|
|
||||||
keyExtractor={(item, index) => item.Id || `episode-${index}`}
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
onScrollToIndexFailed={(info) => {
|
|
||||||
// Bounded retry for scroll failures
|
|
||||||
if (
|
|
||||||
scrollRetryCountRef.current >= MAX_SCROLL_RETRIES ||
|
|
||||||
info.index >= filteredEpisodes.length
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollRetryCountRef.current += 1;
|
|
||||||
if (scrollRetryTimeoutRef.current) {
|
|
||||||
clearTimeout(scrollRetryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
scrollRetryTimeoutRef.current = setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: info.index,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5,
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Settings Menu
|
|
||||||
* Configure version, quality (bitrate cap), audio, subtitles, and playback speed.
|
|
||||||
* Every "selected" row is driven by the active CastSelection — no [0] fallbacks.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
export interface VersionOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QualityOption {
|
|
||||||
key: string;
|
|
||||||
value: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChromecastSettingsMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
versions: VersionOption[];
|
|
||||||
selectedVersionId: string;
|
|
||||||
onVersionChange: (id: string) => void;
|
|
||||||
qualities: QualityOption[];
|
|
||||||
selectedMaxBitrate: number | undefined;
|
|
||||||
onQualityChange: (value: number | undefined) => void;
|
|
||||||
audioTracks: AudioTrack[];
|
|
||||||
selectedAudioIndex: number;
|
|
||||||
onAudioChange: (index: number) => void;
|
|
||||||
subtitleTracks: SubtitleTrack[];
|
|
||||||
/** -1 = subtitles off. */
|
|
||||||
selectedSubtitleIndex: number;
|
|
||||||
onSubtitleChange: (index: number) => void;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onPlaybackSpeedChange: (speed: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
||||||
const ACCENT = "#a855f7";
|
|
||||||
|
|
||||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
versions,
|
|
||||||
selectedVersionId,
|
|
||||||
onVersionChange,
|
|
||||||
qualities,
|
|
||||||
selectedMaxBitrate,
|
|
||||||
onQualityChange,
|
|
||||||
audioTracks,
|
|
||||||
selectedAudioIndex,
|
|
||||||
onAudioChange,
|
|
||||||
subtitleTracks,
|
|
||||||
selectedSubtitleIndex,
|
|
||||||
onSubtitleChange,
|
|
||||||
playbackSpeed,
|
|
||||||
onPlaybackSpeedChange,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
|
||||||
setExpandedSection(expandedSection === section ? null : section);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSectionHeader = (
|
|
||||||
title: string,
|
|
||||||
icon: keyof typeof Ionicons.glyphMap,
|
|
||||||
sectionKey: string,
|
|
||||||
) => (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => toggleSection(sectionKey)}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
||||||
<Ionicons name={icon} size={20} color='white' />
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
|
||||||
size={20}
|
|
||||||
color='#999'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (
|
|
||||||
key: string | number,
|
|
||||||
label: string,
|
|
||||||
sublabel: string | null,
|
|
||||||
selected: boolean,
|
|
||||||
onPress: () => void,
|
|
||||||
) => (
|
|
||||||
<Pressable
|
|
||||||
key={key}
|
|
||||||
onPress={() => {
|
|
||||||
onPress();
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor: selected ? "#2a2a2a" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>{label}</Text>
|
|
||||||
{sublabel ? (
|
|
||||||
<Text style={{ color: "#999", fontSize: 13, marginTop: 2 }}>
|
|
||||||
{sublabel}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{selected ? <Ionicons name='checkmark' size={20} color={ACCENT} /> : null}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
maxHeight: "80%",
|
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
{t("casting_player.playback_settings")}
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView>
|
|
||||||
{/* Version — only when the item has more than one MediaSource */}
|
|
||||||
{versions.length > 1 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.version"),
|
|
||||||
"albums-outline",
|
|
||||||
"version",
|
|
||||||
)}
|
|
||||||
{versions.length > 1 && expandedSection === "version" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{versions.map((v) =>
|
|
||||||
renderRow(
|
|
||||||
v.id,
|
|
||||||
v.name,
|
|
||||||
null,
|
|
||||||
v.id === selectedVersionId,
|
|
||||||
() => onVersionChange(v.id),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality (bitrate cap) */}
|
|
||||||
{renderSectionHeader(
|
|
||||||
t("casting_player.quality"),
|
|
||||||
"film-outline",
|
|
||||||
"quality",
|
|
||||||
)}
|
|
||||||
{expandedSection === "quality" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{qualities.map((q) =>
|
|
||||||
renderRow(
|
|
||||||
q.key,
|
|
||||||
q.key,
|
|
||||||
null,
|
|
||||||
q.value === selectedMaxBitrate,
|
|
||||||
() => onQualityChange(q.value),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio — only when more than one track */}
|
|
||||||
{audioTracks.length > 1 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.audio"),
|
|
||||||
"musical-notes",
|
|
||||||
"audio",
|
|
||||||
)}
|
|
||||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{audioTracks.map((track) =>
|
|
||||||
renderRow(
|
|
||||||
track.index,
|
|
||||||
track.displayTitle ||
|
|
||||||
track.language ||
|
|
||||||
t("casting_player.unknown"),
|
|
||||||
track.codec ? track.codec.toUpperCase() : null,
|
|
||||||
track.index === selectedAudioIndex,
|
|
||||||
() => onAudioChange(track.index),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitles */}
|
|
||||||
{subtitleTracks.length > 0 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.subtitles"),
|
|
||||||
"text",
|
|
||||||
"subtitles",
|
|
||||||
)}
|
|
||||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{renderRow(
|
|
||||||
"off",
|
|
||||||
t("casting_player.none"),
|
|
||||||
null,
|
|
||||||
selectedSubtitleIndex < 0,
|
|
||||||
() => onSubtitleChange(-1),
|
|
||||||
)}
|
|
||||||
{subtitleTracks.map((track) =>
|
|
||||||
renderRow(
|
|
||||||
track.index,
|
|
||||||
track.displayTitle ||
|
|
||||||
track.language ||
|
|
||||||
t("casting_player.unknown"),
|
|
||||||
[
|
|
||||||
track.codec ? track.codec.toUpperCase() : "",
|
|
||||||
track.isForced ? t("casting_player.forced") : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" • ") || null,
|
|
||||||
track.index === selectedSubtitleIndex,
|
|
||||||
() => onSubtitleChange(track.index),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playback speed */}
|
|
||||||
{renderSectionHeader(
|
|
||||||
t("casting_player.playback_speed"),
|
|
||||||
"speedometer",
|
|
||||||
"speed",
|
|
||||||
)}
|
|
||||||
{expandedSection === "speed" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{PLAYBACK_SPEEDS.map((speed) =>
|
|
||||||
renderRow(
|
|
||||||
speed,
|
|
||||||
speed === 1 ? t("casting_player.normal") : `${speed}x`,
|
|
||||||
null,
|
|
||||||
Math.abs(playbackSpeed - speed) < 0.01,
|
|
||||||
() => onPlaybackSpeedChange(speed),
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
|
||||||
* Integrates with autoskip API for segment detection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { isWithinSegment } from "@/utils/casting/helpers";
|
|
||||||
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
|
||||||
import { useSegments } from "@/utils/segments";
|
|
||||||
|
|
||||||
export const useChromecastSegments = (
|
|
||||||
item: BaseItemDto | null,
|
|
||||||
currentProgressMs: number,
|
|
||||||
isOffline = false,
|
|
||||||
) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Fetch segments from autoskip API
|
|
||||||
const { data: segmentData } = useSegments(
|
|
||||||
item?.Id || "",
|
|
||||||
isOffline,
|
|
||||||
undefined, // downloadedFiles parameter
|
|
||||||
api,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse segments into usable format
|
|
||||||
const segments = useMemo<ChromecastSegmentData>(() => {
|
|
||||||
if (!segmentData) {
|
|
||||||
return {
|
|
||||||
intro: null,
|
|
||||||
credits: null,
|
|
||||||
recap: null,
|
|
||||||
commercial: [],
|
|
||||||
preview: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const intro =
|
|
||||||
segmentData.introSegments && segmentData.introSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.introSegments[0].startTime,
|
|
||||||
end: segmentData.introSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const credits =
|
|
||||||
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.creditSegments[0].startTime,
|
|
||||||
end: segmentData.creditSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const recap =
|
|
||||||
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.recapSegments[0].startTime,
|
|
||||||
end: segmentData.recapSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
|
||||||
start: seg.startTime,
|
|
||||||
end: seg.endTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
|
||||||
start: seg.startTime,
|
|
||||||
end: seg.endTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { intro, credits, recap, commercial, preview };
|
|
||||||
}, [segmentData]);
|
|
||||||
|
|
||||||
// Check which segment we're currently in
|
|
||||||
// currentProgressMs is in milliseconds; isWithinSegment() converts ms→seconds internally
|
|
||||||
// before comparing with segment times (which are in seconds from the autoskip API)
|
|
||||||
const currentSegment = useMemo(() => {
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
|
||||||
return { type: "intro" as const, segment: segments.intro };
|
|
||||||
}
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
|
||||||
return { type: "credits" as const, segment: segments.credits };
|
|
||||||
}
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
|
||||||
return { type: "recap" as const, segment: segments.recap };
|
|
||||||
}
|
|
||||||
for (const commercial of segments.commercial) {
|
|
||||||
if (isWithinSegment(currentProgressMs, commercial)) {
|
|
||||||
return { type: "commercial" as const, segment: commercial };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const preview of segments.preview) {
|
|
||||||
if (isWithinSegment(currentProgressMs, preview)) {
|
|
||||||
return { type: "preview" as const, segment: preview };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [currentProgressMs, segments]);
|
|
||||||
|
|
||||||
// Skip functions
|
|
||||||
const skipIntro = useCallback(
|
|
||||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
|
||||||
if (segments.intro) {
|
|
||||||
await seekFn(segments.intro.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.intro],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipCredits = useCallback(
|
|
||||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
|
||||||
if (segments.credits) {
|
|
||||||
await seekFn(segments.credits.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.credits],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
async (seekFn: (positionMs: number) => Promise<void>): Promise<void> => {
|
|
||||||
if (currentSegment?.segment) {
|
|
||||||
await seekFn(currentSegment.segment.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentSegment],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-skip logic based on settings
|
|
||||||
const shouldAutoSkip = useMemo(() => {
|
|
||||||
if (!currentSegment) return false;
|
|
||||||
|
|
||||||
switch (currentSegment.type) {
|
|
||||||
case "intro":
|
|
||||||
return settings?.skipIntro === "auto";
|
|
||||||
case "credits":
|
|
||||||
return settings?.skipOutro === "auto";
|
|
||||||
case "recap":
|
|
||||||
return settings?.skipRecap === "auto";
|
|
||||||
case "commercial":
|
|
||||||
return settings?.skipCommercial === "auto";
|
|
||||||
case "preview":
|
|
||||||
return settings?.skipPreview === "auto";
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
currentSegment,
|
|
||||||
settings?.skipIntro,
|
|
||||||
settings?.skipOutro,
|
|
||||||
settings?.skipRecap,
|
|
||||||
settings?.skipCommercial,
|
|
||||||
settings?.skipPreview,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
segments,
|
|
||||||
currentSegment,
|
|
||||||
skipIntro,
|
|
||||||
skipCredits,
|
|
||||||
skipSegment,
|
|
||||||
shouldAutoSkip,
|
|
||||||
hasIntro: !!segments.intro,
|
|
||||||
hasCredits: !!segments.credits,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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) => {
|
||||||
@@ -60,7 +47,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
key={`${person.Id}-${idx}`}
|
key={person.Id}
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
actorId={person.Id}
|
actorId={person.Id}
|
||||||
actorName={person.Name}
|
actorName={person.Name}
|
||||||
|
|||||||
@@ -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,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Player-agnostic "next episode" countdown card. The parent owns the timer and
|
|
||||||
* positioning — this component only renders the next episode's poster, title,
|
|
||||||
* the remaining seconds, and the Play-now / Cancel actions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface AutoplayCountdownProps {
|
|
||||||
/** The episode that will play next. */
|
|
||||||
nextEpisode: BaseItemDto;
|
|
||||||
/** Poster image URL for the next episode, or null. */
|
|
||||||
posterUrl: string | null;
|
|
||||||
/** Seconds left before the next episode plays. */
|
|
||||||
secondsRemaining: number;
|
|
||||||
/** Play the next episode immediately. */
|
|
||||||
onPlayNow: () => void;
|
|
||||||
/** Cancel autoplay — the next episode will not play. */
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AutoplayCountdown({
|
|
||||||
nextEpisode,
|
|
||||||
posterUrl,
|
|
||||||
secondsRemaining,
|
|
||||||
onPlayNow,
|
|
||||||
onCancel,
|
|
||||||
}: AutoplayCountdownProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 12,
|
|
||||||
width: 320,
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: "rgba(20, 20, 20, 0.94)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{posterUrl && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{ width: 62, height: 93, borderRadius: 6 }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View style={{ flex: 1, justifyContent: "space-between" }}>
|
|
||||||
<View style={{ gap: 2 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>
|
|
||||||
{t("player.up_next")}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ color: "#fff", fontSize: 15, fontWeight: "600" }}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{nextEpisode.Name}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "#a855f7", fontSize: 13 }}>
|
|
||||||
{t("player.next_episode_in", { seconds: secondsRemaining })}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={{ flexDirection: "row", gap: 8, marginTop: 8 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={onPlayNow}
|
|
||||||
accessibilityRole='button'
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: "#a855f7",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
|
||||||
{t("player.play_now")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={onCancel}
|
|
||||||
accessibilityRole='button'
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: "#333",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#fff", fontWeight: "600" }}>
|
|
||||||
{t("player.cancel")}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
selection={jellyseerrOrderBy as unknown as string}
|
||||||
|
onSelectionChange={(value) => {
|
||||||
|
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Menu
|
{sortOptions.map((item) => (
|
||||||
label={`${t("library.filters.sort_by")}: ${t(
|
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
||||||
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
|
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
||||||
)}`}
|
</SwiftUIText>
|
||||||
|
))}
|
||||||
|
</Picker>
|
||||||
|
<Picker
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
selection={jellyseerrSortOrder}
|
||||||
|
onSelectionChange={(value) => {
|
||||||
|
setJellyseerrSortOrder(value as "asc" | "desc");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{sortOptions.map((item) => {
|
{orderOptions.map((item) => (
|
||||||
const isSelected =
|
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
||||||
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
|
{t(`library.filters.${item}`)}
|
||||||
return (
|
</SwiftUIText>
|
||||||
<Button
|
))}
|
||||||
key={item}
|
</Picker>
|
||||||
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
</ContextMenu.Items>
|
||||||
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
</ContextMenu>
|
||||||
onPress={() =>
|
|
||||||
setJellyseerrOrderBy(
|
|
||||||
item as unknown as JellyseerrSearchSort,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
<Menu
|
|
||||||
label={`${t("library.filters.sort_order")}: ${t(
|
|
||||||
`library.filters.${jellyseerrSortOrder}`,
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{orderOptions.map((item) => {
|
|
||||||
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,43 +215,39 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
{/* Sticky header: search field stays pinned while results scroll below. */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
|
||||||
module). It renders the native search bar + grid keyboard and
|
|
||||||
forwards typed text into the existing query pipeline via setSearch;
|
|
||||||
our own results grid renders below. */}
|
|
||||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
|
||||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
|
||||||
margins squeeze the bar's width and clip that trailing hint, so let
|
|
||||||
the native view span the full width and own its own insets. */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginBottom: 24,
|
|
||||||
height: SEARCH_AREA_HEIGHT,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TvSearchView
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardDismissMode='on-drag'
|
keyboardDismissMode='on-drag'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + 60,
|
paddingBottom: insets.bottom + 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Search Input */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginBottom: 24,
|
||||||
|
marginHorizontal: HORIZONTAL_PADDING + 200,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
hasTVPreferredFocus={
|
||||||
|
debouncedSearch.length === 0 &&
|
||||||
|
sections.length === 0 &&
|
||||||
|
!showDiscover
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Search Type Tab Badges */}
|
{/* Search Type Tab Badges */}
|
||||||
{showDiscover && (
|
{showDiscover && (
|
||||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||||
@@ -283,17 +270,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
{/* Library Search Results */}
|
{/* Library Search Results */}
|
||||||
{isLibraryMode && !loading && (
|
{isLibraryMode && !loading && (
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{sections.map((section) => (
|
{sections.map((section, index) => (
|
||||||
<TVSearchSection
|
<TVSearchSection
|
||||||
key={section.key}
|
key={section.key}
|
||||||
title={section.title}
|
title={section.title}
|
||||||
items={section.items!}
|
items={section.items!}
|
||||||
orientation={section.orientation || "vertical"}
|
orientation={section.orientation || "vertical"}
|
||||||
// Never auto-focus a result. The native search field owns focus
|
isFirstSection={index === 0}
|
||||||
// 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}
|
onItemPress={onItemPress}
|
||||||
onItemLongPress={onItemLongPress}
|
onItemLongPress={onItemLongPress}
|
||||||
imageUrlGetter={
|
imageUrlGetter={
|
||||||
@@ -324,9 +307,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Discover Content (when no search query in Discover mode) */}
|
{/* Discover Content (when no search query in Discover mode) */}
|
||||||
{isDiscoverMode &&
|
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
|
||||||
!jellyseerrLoading &&
|
|
||||||
debouncedSearch.length === 0 && (
|
|
||||||
<TVDiscover sliders={discoverSliders} />
|
<TVDiscover sliders={discoverSliders} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -354,6 +335,5 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,62 +1,18 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Switch, View } from "react-native";
|
||||||
import { useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { ChromecastProfileMode } from "@/utils/casting/capabilities";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { ListGroup } from "../list/ListGroup";
|
import { ListGroup } from "../list/ListGroup";
|
||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
import { PlatformDropdown } from "../PlatformDropdown";
|
|
||||||
|
|
||||||
const PROFILE_LABELS: Record<ChromecastProfileMode, string> = {
|
|
||||||
auto: "Automatic (recommended)",
|
|
||||||
"force-hevc": "Force HEVC / H265",
|
|
||||||
"force-h264": "Force H264",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
export const ChromecastSettings: React.FC = ({ ...props }) => {
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
|
|
||||||
const profileOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: (
|
|
||||||
["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[]
|
|
||||||
).map((mode) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: PROFILE_LABELS[mode],
|
|
||||||
value: mode,
|
|
||||||
selected: (settings.chromecastProfile ?? "auto") === mode,
|
|
||||||
onPress: () => updateSettings({ chromecastProfile: mode }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings.chromecastProfile, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View {...props}>
|
<View {...props}>
|
||||||
<ListGroup title={"Chromecast"}>
|
<ListGroup title={"Chromecast"}>
|
||||||
<ListItem
|
<ListItem title={"Enable H265 for Chromecast"}>
|
||||||
title={"Profile"}
|
<Switch
|
||||||
subtitle={
|
value={settings.enableH265ForChromecast}
|
||||||
"Automatic picks codecs per device. Override only if needed."
|
onValueChange={(enableH265ForChromecast) =>
|
||||||
}
|
updateSettings({ enableH265ForChromecast })
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={profileOptions}
|
|
||||||
title={"Chromecast profile"}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{PROFILE_LABELS[settings.chromecastProfile ?? "auto"]}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { BITRATES } from "@/components/BitrateSelector";
|
|||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
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 useRouter from "@/hooks/useAppRouter";
|
|
||||||
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 { Text } from "../common/Text";
|
||||||
@@ -16,7 +15,6 @@ import { ListGroup } from "../list/ListGroup";
|
|||||||
import { ListItem } from "../list/ListItem";
|
import { ListItem } from "../list/ListItem";
|
||||||
|
|
||||||
export const PlaybackControlsSettings: React.FC = () => {
|
export const PlaybackControlsSettings: React.FC = () => {
|
||||||
const router = useRouter();
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -98,48 +96,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
[settings?.maxAutoPlayEpisodeCount?.key, t, updateSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clamp persisted value to the 5-60s bounds so the dropdown always shows a
|
|
||||||
// valid selection even if an out-of-range value was stored previously.
|
|
||||||
const autoplayCountdown = Math.min(
|
|
||||||
60,
|
|
||||||
Math.max(5, settings?.autoplayCountdownSeconds ?? 15),
|
|
||||||
);
|
|
||||||
const castAutoplayCountdown = Math.min(
|
|
||||||
60,
|
|
||||||
Math.max(5, settings?.castAutoplayCountdownSeconds ?? 30),
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoplayCountdownOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: String(seconds),
|
|
||||||
value: String(seconds),
|
|
||||||
selected: seconds === autoplayCountdown,
|
|
||||||
onPress: () => updateSettings({ autoplayCountdownSeconds: seconds }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[autoplayCountdown, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const castAutoplayCountdownOptions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: AUTOPLAY_COUNTDOWN_SECONDS.map((seconds) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: String(seconds),
|
|
||||||
value: String(seconds),
|
|
||||||
selected: seconds === castAutoplayCountdown,
|
|
||||||
onPress: () =>
|
|
||||||
updateSettings({ castAutoplayCountdownSeconds: seconds }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[castAutoplayCountdown, updateSettings],
|
|
||||||
);
|
|
||||||
|
|
||||||
const playbackSpeedOptions = useMemo(
|
const playbackSpeedOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -273,10 +229,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
|
|
||||||
<ListItem
|
<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
|
<PlatformDropdown
|
||||||
groups={autoPlayEpisodeOptions}
|
groups={autoPlayEpisodeOptions}
|
||||||
@@ -295,57 +248,6 @@ export const PlaybackControlsSettings: React.FC = () => {
|
|||||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
{/* Media Segment Skip Settings */}
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.segment_skip_settings")}
|
|
||||||
subtitle={t("home.settings.other.segment_skip_settings_description")}
|
|
||||||
onPress={() => router.push("/settings/segment-skip/page")}
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-forward' size={20} color='#8E8D91' />
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={autoplayCountdownOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>{autoplayCountdown}</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.autoplay_countdown_seconds")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
|
||||||
disabled={!settings.autoPlayNextEpisode}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={castAutoplayCountdownOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{castAutoplayCountdown}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.cast_autoplay_countdown_seconds")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
@@ -366,6 +268,3 @@ const AUTOPLAY_EPISODES_COUNT = (
|
|||||||
{ key: "6", value: 6 },
|
{ key: "6", value: 6 },
|
||||||
{ key: "7", value: 7 },
|
{ key: "7", value: 7 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Selectable next-episode countdown durations, bounded to 5-60 seconds.
|
|
||||||
const AUTOPLAY_COUNTDOWN_SECONDS: number[] = [5, 10, 15, 20, 30, 45, 60];
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user