mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-02 07:52:34 +00:00
Compare commits
19 Commits
refactor-c
...
chromecast
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b12693116 | ||
|
|
6d97f28cb2 | ||
|
|
fd0b6d4a87 | ||
|
|
24b4f212fb | ||
|
|
6eb74d3736 | ||
|
|
46555569e3 | ||
|
|
7827a9e279 | ||
|
|
28eb18ab82 | ||
|
|
ea8e8a9fa7 | ||
|
|
1f1231ce39 | ||
|
|
3b7bc24c76 | ||
|
|
ea3397a026 | ||
|
|
b922b561f5 | ||
|
|
cd977d117e | ||
|
|
962b2d1461 | ||
|
|
c2391ba113 | ||
|
|
a13c0e8108 | ||
|
|
64765c1a4a | ||
|
|
3555ef964e |
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)
|
||||||
|
|||||||
32
.github/workflows/build-apps.yml
vendored
32
.github/workflows/build-apps.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -41,12 +41,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
run: bun run prebuild
|
run: bun run prebuild
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
swap-storage: false
|
swap-storage: false
|
||||||
|
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -124,12 +124,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
@@ -143,7 +143,7 @@ jobs:
|
|||||||
bun run submodule-reload
|
bun run submodule-reload
|
||||||
|
|
||||||
- name: 💾 Cache Gradle global
|
- name: 💾 Cache Gradle global
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -156,7 +156,7 @@ jobs:
|
|||||||
run: bun run prebuild:tv
|
run: bun run prebuild:tv
|
||||||
|
|
||||||
- name: 💾 Cache project Gradle (.gradle)
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: android/.gradle
|
path: android/.gradle
|
||||||
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -195,12 +195,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
@@ -251,7 +251,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout code
|
- name: 📥 Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -259,12 +259,12 @@ jobs:
|
|||||||
show-progress: false
|
show-progress: false
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
|||||||
6
.github/workflows/check-lockfile.yml
vendored
6
.github/workflows/check-lockfile.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
show-progress: false
|
show-progress: false
|
||||||
@@ -27,12 +27,12 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: 💾 Cache Bun dependencies
|
- name: 💾 Cache Bun dependencies
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
|
|||||||
8
.github/workflows/ci-codeql.yml
vendored
8
.github/workflows/ci-codeql.yml
vendored
@@ -24,16 +24,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: 🏁 Initialize CodeQL
|
- name: 🏁 Initialize CodeQL
|
||||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended,security-and-quality
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
- name: 🛠️ Autobuild
|
- name: 🛠️ Autobuild
|
||||||
uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|
||||||
- name: 🧪 Perform CodeQL Analysis
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||||
|
|||||||
4
.github/workflows/crowdin.yml
vendored
4
.github/workflows/crowdin.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout Repository
|
- name: 📥 Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🌐 Sync Translations with Crowdin
|
- name: 🌐 Sync Translations with Crowdin
|
||||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2.14.0
|
uses: crowdin/github-action@60debf382ee245b21794321190ad0501db89d8c1 # v2.13.0
|
||||||
with:
|
with:
|
||||||
upload_sources: true
|
upload_sources: true
|
||||||
upload_translations: true
|
upload_translations: true
|
||||||
|
|||||||
10
.github/workflows/linting.yml
vendored
10
.github/workflows/linting.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -69,14 +69,14 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: 🛒 Checkout repository
|
- name: 🛒 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: 🍞 Setup Bun
|
- name: 🍞 Setup Bun
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "📥 Checkout PR code"
|
- name: "📥 Checkout PR code"
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -112,7 +112,7 @@ jobs:
|
|||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
- name: "🍞 Setup Bun"
|
- name: "🍞 Setup Bun"
|
||||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
|
uses: oven-sh/setup-bun@b7a1c7ccf290d58743029c4f6903da283811b979 # v2.1.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/update-issue-form.yml
vendored
4
.github/workflows/update-issue-form.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 📥 Checkout repository
|
- name: 📥 Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
dry_run: no-push
|
dry_run: no-push
|
||||||
|
|
||||||
- name: 📬 Commit and create pull request
|
- name: 📬 Commit and create pull request
|
||||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||||
with:
|
with:
|
||||||
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
|
||||||
branch: ci-update-bug-report
|
branch: ci-update-bug-report
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -11,7 +11,6 @@ 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 { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
|
||||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
||||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -119,7 +118,6 @@ export default function TabLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<CastingMiniPlayer />
|
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,15 @@ export default function Layout() {
|
|||||||
animation: "fade",
|
animation: "fade",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="google-cast-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ export default function page() {
|
|||||||
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { position, cacheSeconds } = data.nativeEvent;
|
const { position } = data.nativeEvent;
|
||||||
// MPV reports position in seconds, convert to ms
|
// MPV reports position in seconds, convert to ms
|
||||||
const currentTime = position * 1000;
|
const currentTime = position * 1000;
|
||||||
|
|
||||||
@@ -459,12 +459,6 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
// Update cache progress (current position + buffered seconds ahead)
|
|
||||||
if (cacheSeconds !== undefined && cacheSeconds > 0) {
|
|
||||||
const cacheEnd = currentTime + cacheSeconds * 1000;
|
|
||||||
cacheProgress.set(cacheEnd);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const shouldUpdateUrl = wasJustSeeking.get();
|
const shouldUpdateUrl = wasJustSeeking.get();
|
||||||
@@ -939,7 +933,7 @@ export default function page() {
|
|||||||
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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
184
app/(auth)/player/google-cast-player.tsx
Normal file
184
app/(auth)/player/google-cast-player.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { useMemo, useState, useRef } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
|
||||||
|
import GoogleCast, {
|
||||||
|
CastButton,
|
||||||
|
CastContext,
|
||||||
|
CastState,
|
||||||
|
useCastDevice,
|
||||||
|
useCastState,
|
||||||
|
useDevices,
|
||||||
|
useMediaStatus,
|
||||||
|
useRemoteMediaClient,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import ChromecastControls from "@/components/ChromecastControls";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function Player() {
|
||||||
|
const castState = useCastState();
|
||||||
|
|
||||||
|
const client = useRemoteMediaClient();
|
||||||
|
const castDevice = useCastDevice();
|
||||||
|
const devices = useDevices();
|
||||||
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
|
const mediaStatus = useMediaStatus();
|
||||||
|
|
||||||
|
const [wasMediaPlaying, setWasMediaPlaying] = useState(false);
|
||||||
|
const reportPlaybackStopedRef = useRef(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaStatus) return; // media currently playing
|
||||||
|
|
||||||
|
// media was just playing, report playback stopped
|
||||||
|
if (wasMediaPlaying) {
|
||||||
|
reportPlaybackStopedRef.current();
|
||||||
|
setWasMediaPlaying(false);
|
||||||
|
}
|
||||||
|
}, [mediaStatus, wasMediaPlaying]);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await discoveryManager.startDiscovery();
|
||||||
|
})();
|
||||||
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
|
const AndroidCastButton = useCallback(
|
||||||
|
() =>
|
||||||
|
Platform.OS === "android" ? (
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
),
|
||||||
|
[Platform.OS]
|
||||||
|
);
|
||||||
|
|
||||||
|
const GoHomeButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/(home)/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("chromecast.go_home")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChromecastControlsMemoized = useMemo(() => {
|
||||||
|
if (!mediaStatus || !client) return undefined;
|
||||||
|
return (
|
||||||
|
<ChromecastControls
|
||||||
|
mediaStatus={mediaStatus}
|
||||||
|
client={client}
|
||||||
|
setWasMediaPlaying={setWasMediaPlaying}
|
||||||
|
reportPlaybackStopedRef={reportPlaybackStopedRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [mediaStatus, client]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
castState === CastState.NO_DEVICES_AVAILABLE ||
|
||||||
|
castState === CastState.NOT_CONNECTED
|
||||||
|
) {
|
||||||
|
// no devices to connect to
|
||||||
|
if (devices.length === 0) {
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col ">
|
||||||
|
<AndroidCastButton />
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white text-lg">
|
||||||
|
{t("chromecast.no_devices_available")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">
|
||||||
|
{t("chromecast.are_you_on_same_network")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-10">
|
||||||
|
<GoHomeButton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// no device selected
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col ">
|
||||||
|
<AndroidCastButton />
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
background={false}
|
||||||
|
onPress={() => {
|
||||||
|
lightHapticFeedback();
|
||||||
|
CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
|
<Feather name="cast" size={42} color={"white"} />
|
||||||
|
</RoundButton>
|
||||||
|
<Text className="text-white text-xl mt-2">
|
||||||
|
{t("chromecast.no_device_selected")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">
|
||||||
|
{t("chromecast.click_icon_to_connect")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-10">
|
||||||
|
<GoHomeButton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (castState === CastState.CONNECTING) {
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white font-semibold lg mb-2">
|
||||||
|
{t("chromecast.establishing_connection")}
|
||||||
|
</Text>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// connected, but no media playing
|
||||||
|
if (!mediaStatus) {
|
||||||
|
return (
|
||||||
|
<View className="w-screen h-screen flex flex-col ">
|
||||||
|
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||||
|
<Text className="text-white text-lg">
|
||||||
|
{t("chromecast.no_media_selected")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">{t("chromecast.start_playing")}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="px-10">
|
||||||
|
<GoHomeButton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChromecastControlsMemoized;
|
||||||
|
}
|
||||||
43
bun.lock
43
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.90.17",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -51,12 +52,12 @@
|
|||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "16.5.4",
|
"react-i18next": "16.5.3",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.28.6",
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
"@react-native-community/cli": "20.1.1",
|
"@react-native-community/cli": "20.1.0",
|
||||||
"@react-native-tvos/config-tv": "0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.23",
|
"@types/lodash": "4.17.23",
|
||||||
@@ -511,29 +512,29 @@
|
|||||||
|
|
||||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
"@react-native-community/cli": ["@react-native-community/cli@20.1.1", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.1", "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-doctor": "20.1.1", "@react-native-community/cli-server-api": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "@react-native-community/cli-types": "20.1.1", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-aLPUx43+WSeTOaUepR2FBD5a1V0OAZ1QB2DOlRlW4fOEjtBXgv40eM/ho8g3WCvAOKfPvTvx4fZdcuovTyV81Q=="],
|
"@react-native-community/cli": ["@react-native-community/cli@20.1.0", "", { "dependencies": { "@react-native-community/cli-clean": "20.1.0", "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-doctor": "20.1.0", "@react-native-community/cli-server-api": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "@react-native-community/cli-types": "20.1.0", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-6nGQ08w2+EcDwTFC4JFiW/wI2pLwzMrk9thz4um7tKRNW8sADX0IyCsfM2F4rHS720C0UNKYBZE9nAsfp8Vkcw=="],
|
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-ajs2i56MANie/v0bMQ1BmRcrOb6MEvLT2rh/I1CA62NXGqF1Rxv6QwsN84LrADMXHRg8QiEMAIADkyDeQHt7Kg=="],
|
"@react-native-community/cli-config": ["@react-native-community/cli-config@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", "joi": "^17.2.1", "picocolors": "^1.1.1" } }, "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag=="],
|
||||||
|
|
||||||
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-1iUV2rPAyoWPo8EceAFC2vZTF+pEd9YqS87c0aqpbGOFE0gs1rHEB+auVR8CdjzftR4U9sq6m2jrdst0rvpIkg=="],
|
"@react-native-community/cli-config-android": ["@react-native-community/cli-config-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-doepJgLJVqeJb5tNoP9hyFIcoZ1OMGO7QN/YMuCCIjbThUQe/J87XdwPol3Qrjr58KRt9xeBVz+kHeW5mtSutw=="],
|
"@react-native-community/cli-config-apple": ["@react-native-community/cli-config-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.1", "", { "dependencies": { "@react-native-community/cli-config": "20.1.1", "@react-native-community/cli-platform-android": "20.1.1", "@react-native-community/cli-platform-apple": "20.1.1", "@react-native-community/cli-platform-ios": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-eFpg5wWnV7uGqvLemshpgj2trPD8cckqxBuI4nT7sxKF/YpA/e3nnnyytHxPP5EnYfWbMcqfaq8hDJoOnJinGQ=="],
|
"@react-native-community/cli-doctor": ["@react-native-community/cli-doctor@20.1.0", "", { "dependencies": { "@react-native-community/cli-config": "20.1.0", "@react-native-community/cli-platform-android": "20.1.0", "@react-native-community/cli-platform-apple": "20.1.0", "@react-native-community/cli-platform-ios": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", "execa": "^5.0.0", "node-stream-zip": "^1.9.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "semver": "^7.5.2", "wcwidth": "^1.0.1", "yaml": "^2.2.1" } }, "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ=="],
|
||||||
|
|
||||||
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-KPheizJQI0tVvBLy9owzpo+A9qDsDAa87e7a8xNaHnwqGpExnIzFPrbdvrltiZjstU2eB/+/UgNQxYIEd4Oc+g=="],
|
"@react-native-community/cli-platform-android": ["@react-native-community/cli-platform-android@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-android": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.1", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.1", "@react-native-community/cli-tools": "20.1.1", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-mQEjOzRFCcQTrCt73Q/+5WWTfUg6U2vLZv5rPuFiNrLbrwRqxVH3OLaXg5gilJkDTJC80z8iOSsdd8MRxONOig=="],
|
"@react-native-community/cli-platform-apple": ["@react-native-community/cli-platform-apple@20.1.0", "", { "dependencies": { "@react-native-community/cli-config-apple": "20.1.0", "@react-native-community/cli-tools": "20.1.0", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1", "picocolors": "^1.1.1" } }, "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg=="],
|
||||||
|
|
||||||
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.1", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.1" } }, "sha512-6vr10/oSjKkZO/BBgfFJNQTC/0CDF4WrN8iW9ss+Kt6ZL2QrBXLYz7fobrrboOlHwqqs5EyQadlEaNii7gKRJg=="],
|
"@react-native-community/cli-platform-ios": ["@react-native-community/cli-platform-ios@20.1.0", "", { "dependencies": { "@react-native-community/cli-platform-apple": "20.1.0" } }, "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.1", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.1", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "sha512-phHfiCa4WqfKfaoV2vGVR3ZrYQDQTpI1k+C+i6rXAxFGxPuy8IgFFVOSL543qjKPpHBVwLcA+/xAJCVpdyCtVQ=="],
|
"@react-native-community/cli-server-api": ["@react-native-community/cli-server-api@20.1.0", "", { "dependencies": { "@react-native-community/cli-tools": "20.1.0", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", "nocache": "^3.0.1", "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", "ws": "^6.2.3" } }, "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA=="],
|
||||||
|
|
||||||
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.1", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-j+zX/H2X+6ZGneIDj56tZ1Hbnip5nSfnq7yGlMyF/zm3U1hKp3G1jN5v0YEfnz/zEmjr7zruh4Y06KmZrF1lrA=="],
|
"@react-native-community/cli-tools": ["@react-native-community/cli-tools@20.1.0", "", { "dependencies": { "@vscode/sudo-prompt": "^9.0.0", "appdirsjs": "^1.2.4", "execa": "^5.0.0", "find-up": "^5.0.0", "launch-editor": "^2.9.1", "mime": "^2.4.1", "ora": "^5.4.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "semver": "^7.5.2" } }, "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ=="],
|
||||||
|
|
||||||
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.1", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-Tp+s27I/RDONrGvWVj4IzEmga2HhJhXi8ZlZTfycMMyAcv4LG/CTPira+BUZs8nzLAJNrlJ79pVVPJPqQAe+aw=="],
|
"@react-native-community/cli-types": ["@react-native-community/cli-types@20.1.0", "", { "dependencies": { "joi": "^17.2.1" } }, "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA=="],
|
||||||
|
|
||||||
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
|
||||||
|
|
||||||
@@ -601,7 +602,7 @@
|
|||||||
|
|
||||||
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
"@tanstack/react-pacer": ["@tanstack/react-pacer@0.19.1", "", { "dependencies": { "@tanstack/pacer": "0.17.1", "@tanstack/react-store": "^0.8.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-wfGwKLo2gosKr5tsXico+jWJ8LsWsBC8MA1HVtUY/D6dhFduEVizKxRUcvP60I3dRvnoXDbN202g4feJHlivnA=="],
|
||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.90.17", "", { "dependencies": { "@tanstack/query-core": "5.90.17" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-PGc2u9KLwohDUSchjW9MZqeDQJfJDON7y4W7REdNBgiFKxQy+Pf7eGjiFWEj5xPqKzAeHYdAb62IWI1a9UJyGQ=="],
|
||||||
|
|
||||||
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="],
|
||||||
|
|
||||||
@@ -1375,7 +1376,7 @@
|
|||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||||
|
|
||||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||||
|
|
||||||
@@ -1639,7 +1640,7 @@
|
|||||||
|
|
||||||
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
|
||||||
|
|
||||||
"react-i18next": ["react-i18next@16.5.4", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g=="],
|
"react-i18next": ["react-i18next@16.5.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw=="],
|
||||||
|
|
||||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||||
|
|
||||||
@@ -1853,8 +1854,6 @@
|
|||||||
|
|
||||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
||||||
|
|
||||||
"strict-url-sanitise": ["strict-url-sanitise@0.0.1", "", {}, "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg=="],
|
|
||||||
|
|
||||||
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
|
||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
@@ -2243,7 +2242,7 @@
|
|||||||
|
|
||||||
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
"@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
"@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.17", "", {}, "sha512-hDww+RyyYhjhUfoYQ4es6pbgxY7LNiPWxt4l1nJqhByjndxJ7HIjDxTBtfvMr5HwjYavMrd+ids5g4Rfev3lVQ=="],
|
||||||
|
|
||||||
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
"@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef } 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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ChromecastConnectionMenu } from "./chromecast/ChromecastConnectionMenu";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
width = 48,
|
width = 48,
|
||||||
@@ -26,125 +23,66 @@ 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 api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
// Connection menu state
|
|
||||||
const [showConnectionMenu, setShowConnectionMenu] = useState(false);
|
|
||||||
const isConnected = castState === CastState.CONNECTED;
|
|
||||||
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
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
|
const router = useRouter();
|
||||||
|
|
||||||
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 {
|
await discoveryManager.startDiscovery();
|
||||||
// Stop any existing discovery first
|
})();
|
||||||
try {
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
await discoveryManager.stopDiscovery();
|
|
||||||
} catch {
|
|
||||||
// Ignore errors when stopping
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start fresh discovery
|
|
||||||
await discoveryManager.startDiscovery();
|
|
||||||
discoveryAttempts.current = 0; // Reset on success
|
|
||||||
} catch (error) {
|
|
||||||
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
|
// Report video progress to Jellyfin server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api || !user?.Id || !mediaStatus?.mediaInfo?.contentId) {
|
if (
|
||||||
|
!api ||
|
||||||
|
!user?.Id ||
|
||||||
|
!mediaStatus ||
|
||||||
|
!mediaStatus.mediaInfo?.contentId
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamPosition = mediaStatus.streamPosition || 0;
|
const streamPosition = mediaStatus.streamPosition || 0;
|
||||||
const playerState = mediaStatus.playerState || null;
|
|
||||||
|
|
||||||
// Report every 10 seconds OR immediately when playerState changes (pause/resume)
|
// Report every 10 seconds
|
||||||
const positionChanged =
|
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
|
||||||
Math.abs(streamPosition - lastReportedProgressRef.current) >= 10;
|
|
||||||
const stateChanged = playerState !== lastReportedPlayerStateRef.current;
|
|
||||||
if (!positionChanged && !stateChanged) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentId = mediaStatus.mediaInfo.contentId;
|
const contentId = mediaStatus.mediaInfo.contentId;
|
||||||
|
|
||||||
// Generate a new PlaySessionId when the content changes
|
|
||||||
if (contentId !== lastContentIdRef.current) {
|
|
||||||
const randomBytes = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(randomBytes);
|
|
||||||
// Format as UUID v4
|
|
||||||
randomBytes[6] = (randomBytes[6] & 0x0f) | 0x40; // Version 4
|
|
||||||
randomBytes[8] = (randomBytes[8] & 0x3f) | 0x80; // Variant 10
|
|
||||||
const uuid = Array.from(randomBytes, (b, i) => {
|
|
||||||
const hex = b.toString(16).padStart(2, "0");
|
|
||||||
return [4, 6, 8, 10].includes(i) ? `-${hex}` : hex;
|
|
||||||
}).join("");
|
|
||||||
playSessionIdRef.current = uuid;
|
|
||||||
lastContentIdRef.current = contentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
const positionTicks = Math.floor(streamPosition * 10000000);
|
||||||
const isPaused = mediaStatus.playerState === "paused";
|
const isPaused = mediaStatus.playerState === "paused";
|
||||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
||||||
const isTranscoding = /m3u8/i.test(streamUrl);
|
const isTranscoding = streamUrl.includes("m3u8");
|
||||||
|
|
||||||
const progressInfo: PlaybackProgressInfo = {
|
const progressInfo: PlaybackProgressInfo = {
|
||||||
ItemId: contentId,
|
ItemId: contentId,
|
||||||
PositionTicks: positionTicks,
|
PositionTicks: positionTicks,
|
||||||
IsPaused: isPaused,
|
IsPaused: isPaused,
|
||||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
||||||
PlaySessionId: playSessionIdRef.current || contentId,
|
PlaySessionId: contentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
getPlaystateApi(api)
|
getPlaystateApi(api)
|
||||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
lastReportedProgressRef.current = streamPosition;
|
lastReportedProgressRef.current = streamPosition;
|
||||||
lastReportedPlayerStateRef.current = playerState;
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to report Chromecast progress:", error);
|
console.error("Failed to report Chromecast progress:", error);
|
||||||
@@ -154,8 +92,6 @@ export function Chromecast({
|
|||||||
user?.Id,
|
user?.Id,
|
||||||
mediaStatus?.streamPosition,
|
mediaStatus?.streamPosition,
|
||||||
mediaStatus?.mediaInfo?.contentId,
|
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
|
||||||
@@ -165,92 +101,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'
|
||||||
<AndroidCastButton />
|
onPress={() => {
|
||||||
<Feather
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
name='cast'
|
else CastContext.showCastDialog();
|
||||||
size={22}
|
}}
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
{...props}
|
||||||
/>
|
>
|
||||||
</Pressable>
|
<AndroidCastButton />
|
||||||
<ChromecastConnectionMenu
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
visible={showConnectionMenu}
|
</Pressable>
|
||||||
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={() => {
|
||||||
onPress={handlePress}
|
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
|
||||||
{...props}
|
else CastContext.showCastDialog();
|
||||||
>
|
}}
|
||||||
<AndroidCastButton />
|
{...props}
|
||||||
<Feather
|
>
|
||||||
name='cast'
|
<AndroidCastButton />
|
||||||
size={22}
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
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'
|
||||||
<AndroidCastButton />
|
onPress={() => {
|
||||||
<Feather
|
if (mediaStatus?.currentItemId) router.push('/player/google-cast-player');
|
||||||
name='cast'
|
else CastContext.showCastDialog();
|
||||||
size={22}
|
}}
|
||||||
color={isConnected ? "#a855f7" : "white"}
|
{...props}
|
||||||
/>
|
>
|
||||||
</RoundButton>
|
<AndroidCastButton />
|
||||||
<ChromecastConnectionMenu
|
<Feather name='cast' size={22} color={"white"} />
|
||||||
visible={showConnectionMenu}
|
</RoundButton>
|
||||||
onClose={() => setShowConnectionMenu(false)}
|
|
||||||
onDisconnect={handleDisconnect}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
897
components/ChromecastControls.tsx
Normal file
897
components/ChromecastControls.tsx
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { RoundButton } from "@/components/RoundButton";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CastButton,
|
||||||
|
CastContext,
|
||||||
|
MediaStatus,
|
||||||
|
RemoteMediaClient,
|
||||||
|
useStreamPosition,
|
||||||
|
} from "react-native-google-cast";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { Slider } from "react-native-awesome-slider";
|
||||||
|
import {
|
||||||
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
|
useSharedValue,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
import SkipButton from "@/components/video-player/controls/SkipButton";
|
||||||
|
import NextEpisodeCountDownButton from "@/components/video-player/controls/NextEpisodeCountDownButton";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
|
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||||
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
|
import { secondsToTicks } from "@/utils/secondsToTicks";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import { chromecast as chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import { SelectedOptions } from "./ItemContent";
|
||||||
|
import {
|
||||||
|
getDefaultPlaySettings,
|
||||||
|
previousIndexes,
|
||||||
|
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getPlaystateApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { BitrateSelector } from "@/components/BitrateSelector";
|
||||||
|
import { ItemHeader } from "@/components/ItemHeader";
|
||||||
|
import { MediaSourceSelector } from "@/components/MediaSourceSelector";
|
||||||
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
|
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||||
|
import { ItemTechnicalDetails } from "@/components/ItemTechnicalDetails";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { PlayedStatus } from "./PlayedStatus";
|
||||||
|
import { AddToFavorites } from "./AddToFavorites";
|
||||||
|
|
||||||
|
export default function ChromecastControls({
|
||||||
|
mediaStatus,
|
||||||
|
client,
|
||||||
|
setWasMediaPlaying,
|
||||||
|
reportPlaybackStopedRef,
|
||||||
|
}: {
|
||||||
|
mediaStatus: MediaStatus;
|
||||||
|
client: RemoteMediaClient;
|
||||||
|
setWasMediaPlaying: (wasPlaying: boolean) => void;
|
||||||
|
reportPlaybackStopedRef: React.MutableRefObject<() => void>;
|
||||||
|
}) {
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [remainingTime, setRemainingTime] = useState(Infinity);
|
||||||
|
const max = useSharedValue(mediaStatus.mediaInfo?.streamDuration || 0);
|
||||||
|
|
||||||
|
const streamPosition = useStreamPosition();
|
||||||
|
const progress = useSharedValue(streamPosition || 0);
|
||||||
|
|
||||||
|
const wasPlayingRef = useRef(false);
|
||||||
|
|
||||||
|
const isSeeking = useSharedValue(false);
|
||||||
|
const isPlaying = useMemo(
|
||||||
|
() => mediaStatus.playerState === "playing",
|
||||||
|
[mediaStatus.playerState]
|
||||||
|
);
|
||||||
|
const isBufferingOrLoading = useMemo(
|
||||||
|
() =>
|
||||||
|
mediaStatus.playerState === null ||
|
||||||
|
mediaStatus.playerState === "buffering" ||
|
||||||
|
mediaStatus.playerState === "loading",
|
||||||
|
[mediaStatus.playerState]
|
||||||
|
);
|
||||||
|
|
||||||
|
// request update of media status every player state change
|
||||||
|
useEffect(() => {
|
||||||
|
client.requestStatus();
|
||||||
|
}, [mediaStatus.playerState]);
|
||||||
|
|
||||||
|
// update max progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (mediaStatus.mediaInfo?.streamDuration)
|
||||||
|
max.value = mediaStatus.mediaInfo?.streamDuration;
|
||||||
|
}, [mediaStatus.mediaInfo?.streamDuration]);
|
||||||
|
|
||||||
|
const updateTimes = useCallback(
|
||||||
|
(currentProgress: number, maxValue: number) => {
|
||||||
|
setCurrentTime(currentProgress);
|
||||||
|
setRemainingTime(maxValue - currentProgress);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => ({
|
||||||
|
progress: progress.value,
|
||||||
|
max: max.value,
|
||||||
|
isSeeking: isSeeking.value,
|
||||||
|
}),
|
||||||
|
(result) => {
|
||||||
|
if (result.isSeeking === false) {
|
||||||
|
runOnJS(updateTimes)(result.progress, result.max);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateTimes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mediaMetadata, itemId, streamURL } = useMemo(
|
||||||
|
() => ({
|
||||||
|
mediaMetadata: mediaStatus.mediaInfo?.metadata,
|
||||||
|
itemId: mediaStatus.mediaInfo?.contentId,
|
||||||
|
streamURL: mediaStatus.mediaInfo?.contentUrl,
|
||||||
|
}),
|
||||||
|
[mediaStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const type = useMemo(
|
||||||
|
() => mediaMetadata?.type || "generic",
|
||||||
|
[mediaMetadata?.type]
|
||||||
|
);
|
||||||
|
const images = useMemo(
|
||||||
|
() => mediaMetadata?.images || [],
|
||||||
|
[mediaMetadata?.images]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { playbackOptions, sessionId, mediaSourceId } = useMemo(() => {
|
||||||
|
const mediaCustomData = mediaStatus.mediaInfo?.customData as
|
||||||
|
| {
|
||||||
|
playbackOptions: SelectedOptions;
|
||||||
|
sessionId?: string;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
mediaCustomData || {
|
||||||
|
playbackOptions: undefined,
|
||||||
|
sessionId: undefined,
|
||||||
|
mediaSourceId: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [mediaStatus.mediaInfo?.customData]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: item,
|
||||||
|
// currently nothing is indicating that item is loading, because most of the time it loads very fast
|
||||||
|
isLoading: isLoadingItem,
|
||||||
|
isError: isErrorItem,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["item", itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!itemId) return;
|
||||||
|
const res = await getUserLibraryApi(api!).getItem({
|
||||||
|
itemId,
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!itemId,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onProgress = useCallback(
|
||||||
|
async (progressInTicks: number, isPlaying: boolean) => {
|
||||||
|
if (!item?.Id || !streamURL) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackProgress({
|
||||||
|
itemId: item.Id,
|
||||||
|
audioStreamIndex: playbackOptions?.audioIndex,
|
||||||
|
subtitleStreamIndex: playbackOptions?.subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
positionTicks: Math.floor(progressInTicks),
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: sessionId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[api, item, playbackOptions, mediaSourceId, streamURL, sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// update progess on stream position change
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamPosition) {
|
||||||
|
progress.value = streamPosition;
|
||||||
|
onProgress(secondsToTicks(streamPosition), isPlaying);
|
||||||
|
}
|
||||||
|
}, [streamPosition, isPlaying]);
|
||||||
|
|
||||||
|
const reportPlaybackStart = useCallback(async () => {
|
||||||
|
if (!streamURL) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackStart({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
audioStreamIndex: playbackOptions?.audioIndex,
|
||||||
|
subtitleStreamIndex: playbackOptions?.subtitleIndex,
|
||||||
|
mediaSourceId,
|
||||||
|
playMethod: streamURL.includes("m3u8") ? "Transcode" : "DirectStream",
|
||||||
|
playSessionId: sessionId,
|
||||||
|
});
|
||||||
|
}, [api, item, playbackOptions, mediaSourceId, streamURL, sessionId]);
|
||||||
|
|
||||||
|
// report playback started
|
||||||
|
useEffect(() => {
|
||||||
|
setWasMediaPlaying(true);
|
||||||
|
reportPlaybackStart();
|
||||||
|
}, [reportPlaybackStart]);
|
||||||
|
|
||||||
|
// update the reportPlaybackStoppedRef
|
||||||
|
useEffect(() => {
|
||||||
|
reportPlaybackStopedRef.current = async () => {
|
||||||
|
if (!streamURL) return;
|
||||||
|
|
||||||
|
await getPlaystateApi(api!).onPlaybackStopped({
|
||||||
|
itemId: item?.Id!,
|
||||||
|
mediaSourceId,
|
||||||
|
positionTicks: secondsToTicks(progress.value),
|
||||||
|
playSessionId: sessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
playbackOptions,
|
||||||
|
progress,
|
||||||
|
mediaSourceId,
|
||||||
|
streamURL,
|
||||||
|
sessionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { previousItem, nextItem } = useAdjacentItems({
|
||||||
|
item: {
|
||||||
|
Id: itemId,
|
||||||
|
SeriesId: item?.SeriesId,
|
||||||
|
Type: item?.Type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToItem = useCallback(
|
||||||
|
async (item: BaseItemDto) => {
|
||||||
|
if (!api) {
|
||||||
|
console.warn("Failed to go to item: No api!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousIndexes: previousIndexes = {
|
||||||
|
subtitleIndex: playbackOptions?.subtitleIndex || undefined,
|
||||||
|
audioIndex: playbackOptions?.audioIndex || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
mediaSource,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
subtitleIndex: defaultSubtitleIndex,
|
||||||
|
} = getDefaultPlaySettings(item, settings, previousIndexes, undefined);
|
||||||
|
|
||||||
|
// Get a new URL with the Chromecast device profile:
|
||||||
|
const data = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
deviceProfile: chromecastProfile,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: defaultAudioIndex,
|
||||||
|
// maxStreamingBitrate: playbackOptions.bitrate?.value, // TODO handle bitrate limit
|
||||||
|
subtitleStreamIndex: defaultSubtitleIndex,
|
||||||
|
mediaSourceId: mediaSource?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data?.url) {
|
||||||
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
|
Alert.alert("Client error", "Could not create stream for Chromecast");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chromecastLoadMedia({
|
||||||
|
client,
|
||||||
|
item,
|
||||||
|
contentUrl: data.url,
|
||||||
|
sessionId: data.sessionId || undefined,
|
||||||
|
mediaSourceId: data.mediaSource?.Id || undefined,
|
||||||
|
playbackOptions,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: getParentBackdropImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
|
})!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.requestStatus();
|
||||||
|
},
|
||||||
|
[client, api]
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToNextItem = useCallback(() => {
|
||||||
|
if (!nextItem) {
|
||||||
|
console.warn("Failed to skip to next item: No next item!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lightHapticFeedback();
|
||||||
|
goToItem(nextItem);
|
||||||
|
}, [nextItem, lightHapticFeedback]);
|
||||||
|
|
||||||
|
const goToPreviousItem = useCallback(() => {
|
||||||
|
if (!previousItem) {
|
||||||
|
console.warn("Failed to skip to next item: No next item!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lightHapticFeedback();
|
||||||
|
goToItem(previousItem);
|
||||||
|
}, [previousItem, lightHapticFeedback]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
client.pause();
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
client.play();
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(time: number) => {
|
||||||
|
// skip to next episode if seeking to end (for credit skipping)
|
||||||
|
// with 1 second room to react
|
||||||
|
if (nextItem && time >= max.value - 1) {
|
||||||
|
goToNextItem();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.seek({
|
||||||
|
position: time,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[client, goToNextItem, nextItem, max]
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (isPlaying) pause();
|
||||||
|
else play();
|
||||||
|
}, [isPlaying, play, pause]);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(async () => {
|
||||||
|
if (!settings?.rewindSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lightHapticFeedback();
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = Math.max(0, curr - settings.rewindSkipTime);
|
||||||
|
seek(newTime);
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video backwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying]);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(async () => {
|
||||||
|
if (!settings?.forwardSkipTime) return;
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lightHapticFeedback();
|
||||||
|
try {
|
||||||
|
const curr = progress.value;
|
||||||
|
if (curr !== undefined) {
|
||||||
|
const newTime = curr + settings.forwardSkipTime;
|
||||||
|
seek(Math.max(0, newTime));
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog("ERROR", "Error seeking video forwards", error);
|
||||||
|
}
|
||||||
|
}, [settings, isPlaying]);
|
||||||
|
|
||||||
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
|
itemId,
|
||||||
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||||
|
itemId,
|
||||||
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
|
const AndroidCastButton = useCallback(
|
||||||
|
() =>
|
||||||
|
Platform.OS === "android" ? (
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
),
|
||||||
|
[Platform.OS]
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrickplaySliderMemoized = useMemo(
|
||||||
|
() => (
|
||||||
|
<TrickplaySlider
|
||||||
|
item={item}
|
||||||
|
progress={progress}
|
||||||
|
wasPlayingRef={wasPlayingRef}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
range={{ max }}
|
||||||
|
play={play}
|
||||||
|
pause={pause}
|
||||||
|
seek={seek}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
item,
|
||||||
|
progress,
|
||||||
|
wasPlayingRef,
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
max,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const NextEpisodeButtonMemoized = useMemo(
|
||||||
|
() => (
|
||||||
|
<NextEpisodeCountDownButton
|
||||||
|
show={nextItem !== null && max.value > 0 && remainingTime < 10}
|
||||||
|
onFinish={goToNextItem}
|
||||||
|
onPress={goToNextItem}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[nextItem, max, remainingTime, goToNextItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => images[0]?.url, [images]);
|
||||||
|
|
||||||
|
if (isErrorItem) {
|
||||||
|
return (
|
||||||
|
<View className="w-full h-full flex flex-col items-center justify-center bg-black">
|
||||||
|
<View className="p-12 flex gap-4">
|
||||||
|
<Text className="text-center font-semibold text-red-500 text-lg">
|
||||||
|
{t("chromecast.error_loading_item")}
|
||||||
|
</Text>
|
||||||
|
{error && (
|
||||||
|
<Text className="text-center opacity-80">{error.message}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="flex gap-2 mt-auto mb-20">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex flex-row items-center justify-center gap-2"
|
||||||
|
onPress={() => refetch()}
|
||||||
|
>
|
||||||
|
<Ionicons name="reload" size={24} color={Colors.primary} />
|
||||||
|
<Text className="ml-2 text-purple-600 text-lg">
|
||||||
|
{t("chromecast.retry_load_item")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex flex-row items-center justify-center gap-2"
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/(home)/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="home" size={16} color={Colors.text} />
|
||||||
|
<Text className="ml-2 text-white text-sm underline">
|
||||||
|
{t("chromecast.go_home")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return <Text>Do something when item is undefined</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playbackOptions) {
|
||||||
|
return <Text>Do something when playbackOptions is undefined</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 relative"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* TODO do navigation header properly */}
|
||||||
|
<View
|
||||||
|
className="flex flex-row justify-between absolute w-full top-2 z-50"
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoundButton size="large" icon="arrow-back" />
|
||||||
|
{item.Type !== "Program" && (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<RoundButton
|
||||||
|
size="large"
|
||||||
|
onPress={() => {
|
||||||
|
CastContext.showCastDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AndroidCastButton />
|
||||||
|
<Feather name="cast" size={24} color={"white"} />
|
||||||
|
</RoundButton>
|
||||||
|
<PlayedStatus items={[item]} size="large" />
|
||||||
|
<AddToFavorites item={item} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className={`flex-1 ${loadingLogo ? "opacity-0" : "opacity-100"}`}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
headerImage={
|
||||||
|
<View style={[{ flex: 1 }]}>
|
||||||
|
<ItemImage
|
||||||
|
variant={
|
||||||
|
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||||
|
}
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
<>
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
}}
|
||||||
|
onLoad={() => setLoadingLogo(false)}
|
||||||
|
onError={() => setLoadingLogo(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className="flex flex-col bg-transparent shrink">
|
||||||
|
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||||
|
<ItemHeader item={item} className="mb-4" />
|
||||||
|
{item.Type !== "Program" && !Platform.isTV && (
|
||||||
|
<View className="flex flex-row items-center justify-start w-full h-16">
|
||||||
|
<BitrateSelector
|
||||||
|
className="mr-1"
|
||||||
|
onChange={(val) =>
|
||||||
|
// setSelectedOptions(
|
||||||
|
// (prev) => prev && { ...prev, bitrate: val }
|
||||||
|
// )
|
||||||
|
console.log("new selected options", val)
|
||||||
|
}
|
||||||
|
selected={playbackOptions.bitrate}
|
||||||
|
/>
|
||||||
|
<MediaSourceSelector
|
||||||
|
className="mr-1"
|
||||||
|
item={item}
|
||||||
|
onChange={(val) =>
|
||||||
|
// setSelectedOptions((prev) =>
|
||||||
|
// prev && {
|
||||||
|
// ...prev,
|
||||||
|
// mediaSource: val,
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
console.log("new selected options", val)
|
||||||
|
}
|
||||||
|
selected={playbackOptions.mediaSource}
|
||||||
|
/>
|
||||||
|
<AudioTrackSelector
|
||||||
|
className="mr-1"
|
||||||
|
source={playbackOptions.mediaSource}
|
||||||
|
onChange={(val) => {
|
||||||
|
// setSelectedOptions((prev) =>
|
||||||
|
// prev && {
|
||||||
|
// ...prev,
|
||||||
|
// audioIndex: val,
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
console.log("new selected options", val);
|
||||||
|
}}
|
||||||
|
selected={playbackOptions.audioIndex}
|
||||||
|
/>
|
||||||
|
<SubtitleTrackSelector
|
||||||
|
source={playbackOptions.mediaSource}
|
||||||
|
onChange={(val) =>
|
||||||
|
// setSelectedOptions(
|
||||||
|
// (prev) =>
|
||||||
|
// prev && {
|
||||||
|
// ...prev,
|
||||||
|
// subtitleIndex: val,
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
console.log("new selected options", val)
|
||||||
|
}
|
||||||
|
selected={playbackOptions.subtitleIndex}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<ItemTechnicalDetails source={playbackOptions.mediaSource} />
|
||||||
|
<OverviewText text={item.Overview} className="px-4 mb-4" />
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
<View className="pt-2">
|
||||||
|
{TrickplaySliderMemoized}
|
||||||
|
<View className="flex flex-row items-center justify-between mt-2">
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
{formatTimeString(currentTime, "s")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[12px] text-neutral-400">
|
||||||
|
-{formatTimeString(remainingTime, "s")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex flex-row w-full items-center justify-evenly mt-2 mb-10">
|
||||||
|
<TouchableOpacity onPress={goToPreviousItem} disabled={!previousItem}>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-back-outline"
|
||||||
|
size={30}
|
||||||
|
color={previousItem ? "white" : "gray"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipBackward}>
|
||||||
|
<Ionicons name="play-back-outline" size={30} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => togglePlay()}
|
||||||
|
className="flex w-14 h-14 items-center justify-center"
|
||||||
|
>
|
||||||
|
{!isBufferingOrLoading ? (
|
||||||
|
<Ionicons
|
||||||
|
name={isPlaying ? "pause" : "play"}
|
||||||
|
size={50}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Loader size={"large"} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
|
<Ionicons name="play-forward-outline" size={30} color="white" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={goToNextItem} disabled={!nextItem}>
|
||||||
|
<Ionicons
|
||||||
|
name="play-skip-forward-outline"
|
||||||
|
size={30}
|
||||||
|
color={nextItem ? "white" : "gray"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* TODO find proper placement for these buttons */}
|
||||||
|
{/* <View className="flex flex-row w-full justify-end px-6 pb-6">
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipButton}
|
||||||
|
onPress={skipIntro}
|
||||||
|
buttonText="Skip Intro"
|
||||||
|
/>
|
||||||
|
<SkipButton
|
||||||
|
showButton={showSkipCreditButton}
|
||||||
|
onPress={skipCredit}
|
||||||
|
buttonText="Skip Credits"
|
||||||
|
/>
|
||||||
|
{NextEpisodeButtonMemoized}
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrickplaySliderProps = {
|
||||||
|
item?: BaseItemDto;
|
||||||
|
progress: SharedValue<number>;
|
||||||
|
wasPlayingRef: React.MutableRefObject<boolean>;
|
||||||
|
isPlaying: boolean;
|
||||||
|
isSeeking: SharedValue<boolean>;
|
||||||
|
range: { min?: SharedValue<number>; max: SharedValue<number> };
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
seek: (time: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TrickplaySlider({
|
||||||
|
item,
|
||||||
|
progress,
|
||||||
|
wasPlayingRef,
|
||||||
|
isPlaying,
|
||||||
|
isSeeking,
|
||||||
|
range,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
}: TrickplaySliderProps) {
|
||||||
|
const [isSliding, setIsSliding] = useState(false);
|
||||||
|
const lastProgressRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const min = useSharedValue(range.min?.value || 0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
trickPlayUrl,
|
||||||
|
calculateTrickplayUrl,
|
||||||
|
trickplayInfo,
|
||||||
|
prefetchAllTrickplayImages,
|
||||||
|
} = useTrickplay(
|
||||||
|
{
|
||||||
|
Id: item?.Id,
|
||||||
|
RunTimeTicks: secondsToTicks(progress.value),
|
||||||
|
Trickplay: item?.Trickplay,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
prefetchAllTrickplayImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSliderStart = useCallback(() => {
|
||||||
|
setIsSliding(true);
|
||||||
|
wasPlayingRef.current = isPlaying;
|
||||||
|
lastProgressRef.current = progress.value;
|
||||||
|
|
||||||
|
pause();
|
||||||
|
isSeeking.value = true;
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const handleSliderComplete = useCallback(async (value: number) => {
|
||||||
|
isSeeking.value = false;
|
||||||
|
progress.value = value;
|
||||||
|
setIsSliding(false);
|
||||||
|
|
||||||
|
seek(Math.max(0, Math.floor(value)));
|
||||||
|
if (wasPlayingRef.current === true) play();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
debounce((value: number) => {
|
||||||
|
calculateTrickplayUrl(secondsToTicks(value));
|
||||||
|
const progressInSeconds = Math.floor(value);
|
||||||
|
const hours = Math.floor(progressInSeconds / 3600);
|
||||||
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
||||||
|
const seconds = progressInSeconds % 60;
|
||||||
|
setTime({ hours, minutes, seconds });
|
||||||
|
}, 3),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoizedRenderBubble = useCallback(() => {
|
||||||
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { x, y, url } = trickPlayUrl;
|
||||||
|
const tileWidth = 150;
|
||||||
|
const tileHeight = 150 / trickplayInfo.aspectRatio!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: -62,
|
||||||
|
bottom: 0,
|
||||||
|
paddingTop: 30,
|
||||||
|
paddingBottom: 5,
|
||||||
|
width: tileWidth * 1.5,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: tileWidth,
|
||||||
|
height: tileHeight,
|
||||||
|
alignSelf: "center",
|
||||||
|
transform: [{ scale: 1.4 }],
|
||||||
|
borderRadius: 5,
|
||||||
|
}}
|
||||||
|
className="bg-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
style={{
|
||||||
|
width: 150 * trickplayInfo?.data.TileWidth!,
|
||||||
|
height:
|
||||||
|
(150 / trickplayInfo.aspectRatio!) *
|
||||||
|
trickplayInfo?.data.TileHeight!,
|
||||||
|
transform: [
|
||||||
|
{ translateX: -x * tileWidth },
|
||||||
|
{ translateY: -y * tileHeight },
|
||||||
|
],
|
||||||
|
resizeMode: "cover",
|
||||||
|
}}
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||||
|
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||||
|
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [trickPlayUrl, trickplayInfo, time]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slider
|
||||||
|
theme={{
|
||||||
|
maximumTrackTintColor: "rgba(255,255,255,0.2)",
|
||||||
|
minimumTrackTintColor: "#fff",
|
||||||
|
cacheTrackTintColor: "rgba(255,255,255,0.3)",
|
||||||
|
bubbleBackgroundColor: "#fff",
|
||||||
|
bubbleTextColor: "#666",
|
||||||
|
heartbeatColor: "#999",
|
||||||
|
}}
|
||||||
|
renderThumb={() => null}
|
||||||
|
onSlidingStart={handleSliderStart}
|
||||||
|
onSlidingComplete={handleSliderComplete}
|
||||||
|
onValueChange={handleSliderChange}
|
||||||
|
containerStyle={{
|
||||||
|
borderRadius: 100,
|
||||||
|
}}
|
||||||
|
renderBubble={() => isSliding && memoizedRenderBubble()}
|
||||||
|
sliderHeight={10}
|
||||||
|
thumbWidth={0}
|
||||||
|
progress={progress}
|
||||||
|
minimumValue={min}
|
||||||
|
maximumValue={range.max}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,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;
|
||||||
};
|
};
|
||||||
@@ -198,7 +197,6 @@ const PlatformDropdownComponent = ({
|
|||||||
onOpenChange: controlledOnOpenChange,
|
onOpenChange: controlledOnOpenChange,
|
||||||
onOptionSelect,
|
onOptionSelect,
|
||||||
expoUIConfig,
|
expoUIConfig,
|
||||||
disabled,
|
|
||||||
bottomSheetConfig,
|
bottomSheetConfig,
|
||||||
}: PlatformDropdownProps) => {
|
}: PlatformDropdownProps) => {
|
||||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||||
@@ -233,13 +231,6 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
if (disabled) {
|
|
||||||
return (
|
|
||||||
<View style={{ opacity: 0.5 }} pointerEvents='none'>
|
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Host style={expoUIConfig?.hostStyle}>
|
<Host style={expoUIConfig?.hostStyle}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
@@ -362,14 +353,8 @@ const PlatformDropdownComponent = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
||||||
onPress={handlePress}
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
||||||
activeOpacity={0.7}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<View style={disabled ? { opacity: 0.5 } : undefined}>
|
|
||||||
{trigger || <Text className='text-white'>Open Menu</Text>}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
|
import ios from "@/utils/profiles/ios";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { useRouter } from "expo-router";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Alert, TouchableOpacity, View } from "react-native";
|
||||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import CastContext, {
|
import CastContext, {
|
||||||
CastButton,
|
CastButton,
|
||||||
MediaPlayerState,
|
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -23,28 +29,16 @@ import Animated, {
|
|||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
|
||||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
|
||||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
|
||||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { buildCastMediaInfo } from "@/utils/casting/mediaInfo";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecast } from "@/utils/profiles/chromecast";
|
|
||||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Text } from "./common/Text";
|
import { SelectedOptions } from "./ItemContent";
|
||||||
import type { SelectedOptions } from "./ItemContent";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { chromecastLoadMedia } from "@/utils/chromecastLoadMedia";
|
||||||
|
|
||||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
selectedOptions: SelectedOptions;
|
selectedOptions: SelectedOptions;
|
||||||
colors?: ThemeColors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_DURATION = 500;
|
const ANIMATION_DURATION = 500;
|
||||||
@@ -53,68 +47,60 @@ const MIN_PLAYBACK_WIDTH = 15;
|
|||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
colors,
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const isOffline = useOfflineMode();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showModal, hideModal } = useGlobalModal();
|
|
||||||
|
|
||||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
const [colorAtom] = useAtom(itemThemeColorAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
|
|
||||||
// Use colors prop if provided, otherwise fallback to global atom
|
|
||||||
const effectiveColors = colors || globalColorAtom;
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const startWidth = useSharedValue(0);
|
const startWidth = useSharedValue(0);
|
||||||
const targetWidth = useSharedValue(0);
|
const targetWidth = useSharedValue(0);
|
||||||
const endColor = useSharedValue(effectiveColors);
|
const endColor = useSharedValue(colorAtom);
|
||||||
const startColor = useSharedValue(effectiveColors);
|
const startColor = useSharedValue(colorAtom);
|
||||||
const widthProgress = useSharedValue(0);
|
const widthProgress = useSharedValue(0);
|
||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
if (settings.maxAutoPlayEpisodeCount.value !== -1) {
|
if (!bitrateValue) {
|
||||||
updateSettings({ autoPlayEpisodeCount: 0 });
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/player/direct-player?${q}`);
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
},
|
},
|
||||||
[router, isOffline],
|
[router]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNormalPlayFlow = useCallback(async () => {
|
const onPress = useCallback(async () => {
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
offline: isOffline ? "true" : "false",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
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(
|
||||||
{
|
{
|
||||||
@@ -123,110 +109,72 @@ 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:
|
||||||
await CastContext.getPlayServicesState().then(async (state) => {
|
await CastContext.getPlayServicesState().then(async (state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS) {
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
} else {
|
else {
|
||||||
// Check if user wants H265 for Chromecast
|
// Get a new URL with the Chromecast device profile:
|
||||||
const enableH265 = settings.enableH265ForChromecast;
|
const data = await getStreamUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
deviceProfile: chromecastProfile,
|
||||||
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
||||||
|
userId: user?.Id,
|
||||||
|
audioStreamIndex: selectedOptions.audioIndex,
|
||||||
|
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id,
|
||||||
|
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
||||||
|
});
|
||||||
|
|
||||||
// Validate required parameters before calling getStreamUrl
|
if (!data?.url) {
|
||||||
if (!api) {
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
console.warn("API not available for Chromecast streaming");
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.client_error"),
|
t("player.client_error"),
|
||||||
t("player.missing_parameters"),
|
t("player.could_not_create_stream_for_chromecast")
|
||||||
);
|
|
||||||
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(
|
|
||||||
t("player.client_error"),
|
|
||||||
t("player.missing_parameters"),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a new URL with the Chromecast device profile
|
chromecastLoadMedia({
|
||||||
try {
|
client,
|
||||||
const data = await getStreamUrl({
|
item,
|
||||||
api,
|
contentUrl: data.url,
|
||||||
item,
|
sessionId: data.sessionId || undefined,
|
||||||
deviceProfile: enableH265 ? chromecasth265 : chromecast,
|
mediaSourceId: data.mediaSource?.Id || undefined,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks ?? 0,
|
playbackOptions: selectedOptions,
|
||||||
userId: user.Id,
|
images: [
|
||||||
audioStreamIndex: selectedOptions.audioIndex,
|
{
|
||||||
maxStreamingBitrate: selectedOptions.bitrate?.value,
|
url: getParentBackdropImageUrl({
|
||||||
mediaSourceId: selectedOptions.mediaSource?.Id,
|
api,
|
||||||
subtitleStreamIndex: selectedOptions.subtitleIndex,
|
item,
|
||||||
});
|
quality: 90,
|
||||||
|
width: 2000,
|
||||||
if (!data?.url) {
|
})!,
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
},
|
||||||
Alert.alert(
|
],
|
||||||
t("player.client_error"),
|
}).then(() => {
|
||||||
t("player.could_not_create_stream_for_chromecast"),
|
// state is already set when reopening current media, so skip it here.
|
||||||
);
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
router.push("/player/google-cast-player");
|
||||||
const startTimeSeconds =
|
});
|
||||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
|
||||||
|
|
||||||
client
|
|
||||||
.loadMedia({
|
|
||||||
mediaInfo: buildCastMediaInfo({
|
|
||||||
item,
|
|
||||||
streamUrl: data.url,
|
|
||||||
api,
|
|
||||||
}),
|
|
||||||
startTime: startTimeSeconds,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
// state is already set when reopening current media, so skip it here.
|
|
||||||
if (isOpeningCurrentlyPlayingMedia) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push("/casting-player");
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("[PlayButton] loadMedia failed:", err);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[PlayButton] Cast error:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
item,
|
item,
|
||||||
@@ -238,140 +186,16 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
goToPlayer,
|
|
||||||
isOffline,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onPress = useCallback(async () => {
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
lightHapticFeedback();
|
|
||||||
|
|
||||||
// Check if item is downloaded
|
|
||||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
|
||||||
|
|
||||||
// If already in offline mode, play downloaded file directly
|
|
||||||
if (isOffline && downloadedItem) {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If online but file is downloaded, ask user which version to play
|
|
||||||
if (downloadedItem) {
|
|
||||||
if (Platform.OS === "android") {
|
|
||||||
// Show bottom sheet for Android
|
|
||||||
showModal(
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className='px-4 mt-4 mb-12'>
|
|
||||||
<View className='pb-6'>
|
|
||||||
<Text className='text-2xl font-bold mb-2'>
|
|
||||||
{t("player.downloaded_file_title")}
|
|
||||||
</Text>
|
|
||||||
<Text className='opacity-70 text-base'>
|
|
||||||
{t("player.downloaded_file_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='space-y-3'>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
hideModal();
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
}}
|
|
||||||
color='purple'
|
|
||||||
>
|
|
||||||
{Platform.OS === "android"
|
|
||||||
? "Play downloaded file"
|
|
||||||
: t("player.downloaded_file_yes")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onPress={() => {
|
|
||||||
hideModal();
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
}}
|
|
||||||
color='white'
|
|
||||||
variant='border'
|
|
||||||
>
|
|
||||||
{Platform.OS === "android"
|
|
||||||
? "Stream file"
|
|
||||||
: t("player.downloaded_file_no")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>,
|
|
||||||
{
|
|
||||||
snapPoints: ["35%"],
|
|
||||||
enablePanDownToClose: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Show alert for iOS
|
|
||||||
Alert.alert(
|
|
||||||
t("player.downloaded_file_title"),
|
|
||||||
t("player.downloaded_file_message"),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_yes"),
|
|
||||||
onPress: () => {
|
|
||||||
const queryParams = new URLSearchParams({
|
|
||||||
itemId: item.Id!,
|
|
||||||
offline: "true",
|
|
||||||
playbackPosition:
|
|
||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
|
||||||
});
|
|
||||||
goToPlayer(queryParams.toString());
|
|
||||||
},
|
|
||||||
isPreferred: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_no"),
|
|
||||||
onPress: () => {
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("player.downloaded_file_cancel"),
|
|
||||||
style: "cancel",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not downloaded, proceed with normal flow
|
|
||||||
handleNormalPlayFlow();
|
|
||||||
}, [
|
|
||||||
item,
|
|
||||||
lightHapticFeedback,
|
|
||||||
handleNormalPlayFlow,
|
|
||||||
goToPlayer,
|
|
||||||
t,
|
|
||||||
showModal,
|
|
||||||
hideModal,
|
|
||||||
effectiveColors,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item || !item.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData && userData.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
? Math.max(
|
? Math.max(
|
||||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||||
MIN_PLAYBACK_WIDTH,
|
MIN_PLAYBACK_WIDTH
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
}
|
}
|
||||||
@@ -388,11 +212,11 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
useAnimatedReaction(
|
useAnimatedReaction(
|
||||||
() => effectiveColors,
|
() => colorAtom,
|
||||||
(newColor) => {
|
(newColor) => {
|
||||||
endColor.value = newColor;
|
endColor.value = newColor;
|
||||||
colorChangeProgress.value = 0;
|
colorChangeProgress.value = 0;
|
||||||
@@ -401,19 +225,19 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[effectiveColors],
|
[colorAtom]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout_2 = setTimeout(() => {
|
const timeout_2 = setTimeout(() => {
|
||||||
startColor.value = effectiveColors;
|
startColor.value = colorAtom;
|
||||||
startWidth.value = targetWidth.value;
|
startWidth.value = targetWidth.value;
|
||||||
}, ANIMATION_DURATION);
|
}, ANIMATION_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeout_2);
|
clearTimeout(timeout_2);
|
||||||
};
|
};
|
||||||
}, [effectiveColors, item]);
|
}, [colorAtom, item]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANIMATED STYLES
|
* ANIMATED STYLES
|
||||||
@@ -422,7 +246,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary],
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -430,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
backgroundColor: interpolateColor(
|
backgroundColor: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.primary, endColor.value.primary],
|
[startColor.value.primary, endColor.value.primary]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -438,7 +262,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
width: `${interpolate(
|
width: `${interpolate(
|
||||||
widthProgress.value,
|
widthProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startWidth.value, targetWidth.value],
|
[startWidth.value, targetWidth.value]
|
||||||
)}%`,
|
)}%`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -446,61 +270,83 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
color: interpolateColor(
|
color: interpolateColor(
|
||||||
colorChangeProgress.value,
|
colorChangeProgress.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[startColor.value.text, endColor.value.text],
|
[startColor.value.text, endColor.value.text]
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
/**
|
||||||
|
* *********************
|
||||||
|
*/
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View>
|
||||||
disabled={!item}
|
<TouchableOpacity
|
||||||
accessibilityLabel='Play button'
|
disabled={!item}
|
||||||
accessibilityHint='Tap to play the media'
|
accessibilityLabel="Play button"
|
||||||
onPress={onPress}
|
accessibilityHint="Tap to play the media"
|
||||||
className={"relative flex-1"}
|
onPress={onPress}
|
||||||
>
|
className={`relative`}
|
||||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
{...props}
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
animatedPrimaryStyle,
|
|
||||||
animatedWidthStyle,
|
|
||||||
{
|
|
||||||
height: "100%",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
|
||||||
className='absolute w-full h-full top-0 left-0 rounded-full'
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: effectiveColors.primary,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
className='flex flex-row items-center justify-center bg-transparent rounded-full z-20 h-12 w-full '
|
|
||||||
>
|
>
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
|
||||||
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
<Animated.View
|
||||||
{runtimeTicksToMinutes(
|
style={[
|
||||||
(item?.RunTimeTicks || 0) -
|
animatedPrimaryStyle,
|
||||||
(item?.UserData?.PlaybackPositionTicks || 0),
|
animatedWidthStyle,
|
||||||
)}
|
{
|
||||||
{(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
height: "100%",
|
||||||
</Animated.Text>
|
},
|
||||||
<Animated.Text style={animatedTextStyle}>
|
]}
|
||||||
<Ionicons name='play-circle' size={24} />
|
/>
|
||||||
</Animated.Text>
|
|
||||||
{client && (
|
|
||||||
<Animated.Text style={animatedTextStyle}>
|
|
||||||
<Feather name='cast' size={22} />
|
|
||||||
<CastButton tintColor='transparent' />
|
|
||||||
</Animated.Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
<Animated.View
|
||||||
|
style={[animatedAverageStyle, { opacity: 0.5 }]}
|
||||||
|
className="absolute w-full h-full top-0 left-0 rounded-xl"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colorAtom.primary,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
|
||||||
|
>
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||||
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
|
</Animated.Text>
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Ionicons name="play-circle" size={24} />
|
||||||
|
</Animated.Text>
|
||||||
|
{client && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<Feather name="cast" size={22} />
|
||||||
|
<CastButton tintColor="transparent" />
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
{!client && settings?.openInVLC && (
|
||||||
|
<Animated.Text style={animatedTextStyle}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="vlc"
|
||||||
|
size={18}
|
||||||
|
color={animatedTextStyle.color}
|
||||||
|
/>
|
||||||
|
</Animated.Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* <View className="mt-2 flex flex-row items-center">
|
||||||
|
<Ionicons
|
||||||
|
name="information-circle"
|
||||||
|
size={12}
|
||||||
|
className=""
|
||||||
|
color={"#9BA1A6"}
|
||||||
|
/>
|
||||||
|
<Text className="text-neutral-500 ml-1">
|
||||||
|
{directStream ? "Direct stream" : "Transcoded stream"}
|
||||||
|
</Text>
|
||||||
|
</View> */}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Mini Player
|
|
||||||
* Works with all supported casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { Dimensions, 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 { Text } from "@/components/common/Text";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
formatTime,
|
|
||||||
formatTrickplayTime,
|
|
||||||
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 insets = useSafeAreaInsets();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
const remoteMediaClient = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const currentItem = useMemo(() => {
|
|
||||||
return mediaStatus?.mediaInfo?.customData as BaseItemDto | undefined;
|
|
||||||
}, [mediaStatus?.mediaInfo?.customData]);
|
|
||||||
|
|
||||||
// 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 [scrubPercentage, setScrubPercentage] = useState(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 });
|
|
||||||
|
|
||||||
// Track scrub percentage for bubble positioning
|
|
||||||
if (duration > 0) {
|
|
||||||
setScrubPercentage(value / duration);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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={() => {
|
|
||||||
// Calculate bubble position with edge clamping
|
|
||||||
const screenWidth = Dimensions.get("window").width;
|
|
||||||
const sliderPadding = 8;
|
|
||||||
const thumbWidth = 10; // matches thumbWidth prop on Slider
|
|
||||||
const sliderWidth = screenWidth - sliderPadding * 2;
|
|
||||||
// Adjust thumb position to account for thumb width affecting travel range
|
|
||||||
const effectiveTrackWidth = sliderWidth - thumbWidth;
|
|
||||||
const thumbPosition =
|
|
||||||
thumbWidth / 2 + scrubPercentage * effectiveTrackWidth;
|
|
||||||
|
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
|
||||||
// Show simple time bubble when no trickplay
|
|
||||||
const timeBubbleWidth = 70;
|
|
||||||
const minLeft = -thumbPosition;
|
|
||||||
const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth;
|
|
||||||
const centeredLeft = -timeBubbleWidth / 2;
|
|
||||||
const clampedLeft = Math.max(
|
|
||||||
minLeft,
|
|
||||||
Math.min(maxLeft, centeredLeft),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 12,
|
|
||||||
left: clampedLeft,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{ color: "#fff", fontSize: 11, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{formatTrickplayTime(trickplayTime)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { x, y, url } = trickPlayUrl;
|
|
||||||
const tileWidth = 140; // Smaller preview for mini player
|
|
||||||
const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78);
|
|
||||||
|
|
||||||
// Calculate clamped position for trickplay preview
|
|
||||||
const minLeft = -thumbPosition;
|
|
||||||
const maxLeft = sliderWidth - thumbPosition - tileWidth;
|
|
||||||
const centeredLeft = -tileWidth / 2;
|
|
||||||
const clampedLeft = Math.max(
|
|
||||||
minLeft,
|
|
||||||
Math.min(maxLeft, centeredLeft),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 12,
|
|
||||||
left: clampedLeft,
|
|
||||||
width: tileWidth,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Trickplay image preview */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: tileWidth,
|
|
||||||
height: tileHeight,
|
|
||||||
borderRadius: 6,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
cachePolicy='memory-disk'
|
|
||||||
style={{
|
|
||||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
|
||||||
height:
|
|
||||||
(tileWidth / (trickplayInfo.aspectRatio ?? 1.78)) *
|
|
||||||
(trickplayInfo.data?.TileHeight ?? 1),
|
|
||||||
transform: [
|
|
||||||
{ translateX: -x * tileWidth },
|
|
||||||
{ translateY: -y * tileHeight },
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
source={{ uri: url }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{/* Time overlay */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 2,
|
|
||||||
left: 2,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
paddingVertical: 1,
|
|
||||||
borderRadius: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{ color: "#fff", fontSize: 10, fontWeight: "600" }}
|
|
||||||
>
|
|
||||||
{formatTrickplayTime(trickplayTime)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
sliderHeight={3}
|
|
||||||
thumbWidth={10}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 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,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,351 +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,
|
|
||||||
backgroundColor: isCurrentEpisode ? "#a855f7" : "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,347 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Settings Menu
|
|
||||||
* Allows users to configure audio, subtitles, quality, and playback speed
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
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,
|
|
||||||
MediaSource,
|
|
||||||
SubtitleTrack,
|
|
||||||
} from "@/utils/casting/types";
|
|
||||||
|
|
||||||
interface ChromecastSettingsMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSources: MediaSource[];
|
|
||||||
selectedMediaSource: MediaSource | null;
|
|
||||||
onMediaSourceChange: (source: MediaSource) => void;
|
|
||||||
audioTracks: AudioTrack[];
|
|
||||||
selectedAudioTrack: AudioTrack | null;
|
|
||||||
onAudioTrackChange: (track: AudioTrack) => void;
|
|
||||||
subtitleTracks: SubtitleTrack[];
|
|
||||||
selectedSubtitleTrack: SubtitleTrack | null;
|
|
||||||
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onPlaybackSpeedChange: (speed: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
||||||
|
|
||||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
item: _item, // Reserved for future use (technical info display)
|
|
||||||
mediaSources,
|
|
||||||
selectedMediaSource,
|
|
||||||
onMediaSourceChange,
|
|
||||||
audioTracks,
|
|
||||||
selectedAudioTrack,
|
|
||||||
onAudioTrackChange,
|
|
||||||
subtitleTracks,
|
|
||||||
selectedSubtitleTrack,
|
|
||||||
onSubtitleTrackChange,
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
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()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<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>
|
|
||||||
{/* Quality/Media Source - only show when sources available */}
|
|
||||||
{mediaSources.length > 0 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.quality"),
|
|
||||||
"film-outline",
|
|
||||||
"quality",
|
|
||||||
)}
|
|
||||||
{mediaSources.length > 0 && expandedSection === "quality" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{mediaSources.map((source) => (
|
|
||||||
<Pressable
|
|
||||||
key={source.id}
|
|
||||||
onPress={() => {
|
|
||||||
onMediaSourceChange(source);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedMediaSource?.id === source.id
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{source.name}
|
|
||||||
</Text>
|
|
||||||
{source.bitrate && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{Math.round(source.bitrate / 1000000)} Mbps
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedMediaSource?.id === source.id && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio Tracks - only show if 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) => (
|
|
||||||
<Pressable
|
|
||||||
key={track.index}
|
|
||||||
onPress={() => {
|
|
||||||
onAudioTrackChange(track);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedAudioTrack?.index === track.index
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{track.displayTitle ||
|
|
||||||
track.language ||
|
|
||||||
t("casting_player.unknown")}
|
|
||||||
</Text>
|
|
||||||
{track.codec && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{track.codec.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedAudioTrack?.index === track.index && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitle Tracks - only show if subtitles available */}
|
|
||||||
{subtitleTracks.length > 0 &&
|
|
||||||
renderSectionHeader(
|
|
||||||
t("casting_player.subtitles"),
|
|
||||||
"text",
|
|
||||||
"subtitles",
|
|
||||||
)}
|
|
||||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSubtitleTrackChange(null);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSubtitleTrack === null
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{t("casting_player.none")}
|
|
||||||
</Text>
|
|
||||||
{selectedSubtitleTrack === null && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
{subtitleTracks.map((track) => (
|
|
||||||
<Pressable
|
|
||||||
key={track.index}
|
|
||||||
onPress={() => {
|
|
||||||
onSubtitleTrackChange(track);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSubtitleTrack?.index === track.index
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{track.displayTitle ||
|
|
||||||
track.language ||
|
|
||||||
t("casting_player.unknown")}
|
|
||||||
</Text>
|
|
||||||
{(track.codec || track.isForced) && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{track.codec ? track.codec.toUpperCase() : ""}
|
|
||||||
{track.isForced && ` • ${t("casting_player.forced")}`}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedSubtitleTrack?.index === track.index && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playback Speed */}
|
|
||||||
{renderSectionHeader(
|
|
||||||
t("casting_player.playback_speed"),
|
|
||||||
"speedometer",
|
|
||||||
"speed",
|
|
||||||
)}
|
|
||||||
{expandedSection === "speed" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{PLAYBACK_SPEEDS.map((speed) => (
|
|
||||||
<Pressable
|
|
||||||
key={speed}
|
|
||||||
onPress={() => {
|
|
||||||
onPlaybackSpeedChange(speed);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
Math.abs(playbackSpeed - speed) < 0.01
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{speed === 1 ? t("casting_player.normal") : `${speed}x`}
|
|
||||||
</Text>
|
|
||||||
{Math.abs(playbackSpeed - speed) < 0.01 && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -47,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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -250,15 +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>
|
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ interface BottomControlsProps {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
remainingTime: number;
|
remainingTime: number;
|
||||||
showSkipButton: boolean;
|
showSkipButton: boolean;
|
||||||
skipButtonText: string;
|
|
||||||
showSkipCreditButton: boolean;
|
showSkipCreditButton: boolean;
|
||||||
skipCreditButtonText: string;
|
|
||||||
hasContentAfterCredits: boolean;
|
hasContentAfterCredits: boolean;
|
||||||
skipIntro: () => void;
|
skipIntro: () => void;
|
||||||
skipCredit: () => void;
|
skipCredit: () => void;
|
||||||
@@ -69,9 +67,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
currentTime,
|
currentTime,
|
||||||
remainingTime,
|
remainingTime,
|
||||||
showSkipButton,
|
showSkipButton,
|
||||||
skipButtonText,
|
|
||||||
showSkipCreditButton,
|
showSkipCreditButton,
|
||||||
skipCreditButtonText,
|
|
||||||
hasContentAfterCredits,
|
hasContentAfterCredits,
|
||||||
skipIntro,
|
skipIntro,
|
||||||
skipCredit,
|
skipCredit,
|
||||||
@@ -140,7 +136,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
<SkipButton
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
buttonText={skipButtonText}
|
buttonText='Skip Intro'
|
||||||
/>
|
/>
|
||||||
{/* Smart Skip Credits behavior:
|
{/* Smart Skip Credits behavior:
|
||||||
- Show "Skip Credits" if there's content after credits OR no next episode
|
- Show "Skip Credits" if there's content after credits OR no next episode
|
||||||
@@ -150,7 +146,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
|
||||||
}
|
}
|
||||||
onPress={skipCredit}
|
onPress={skipCredit}
|
||||||
buttonText={skipCreditButtonText}
|
buttonText='Skip Credits'
|
||||||
/>
|
/>
|
||||||
{settings.autoPlayNextEpisode !== false &&
|
{settings.autoPlayNextEpisode !== false &&
|
||||||
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
(settings.maxAutoPlayEpisodeCount.value === -1 ||
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client";
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import { type FC, useCallback, useEffect, useState } from "react";
|
||||||
type FC,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -24,17 +16,17 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useSegmentSkipper } from "@/hooks/useSegmentSkipper";
|
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||||
import { useSegments } from "@/utils/segments";
|
import { ticksToMs } from "@/utils/time";
|
||||||
import { msToSeconds, ticksToMs } from "@/utils/time";
|
|
||||||
import { BottomControls } from "./BottomControls";
|
import { BottomControls } from "./BottomControls";
|
||||||
import { CenterControls } from "./CenterControls";
|
import { CenterControls } from "./CenterControls";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
@@ -50,9 +42,6 @@ import { useControlsTimeout } from "./useControlsTimeout";
|
|||||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||||
|
|
||||||
// No-op function to avoid creating new references on every render
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -121,24 +110,6 @@ export const Controls: FC<Props> = ({
|
|||||||
const [episodeView, setEpisodeView] = useState(false);
|
const [episodeView, setEpisodeView] = useState(false);
|
||||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||||
|
|
||||||
// Ref to track pending play timeout for cleanup and cancellation
|
|
||||||
const playTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// Mutable ref tracking isPlaying to avoid stale closures in seekMs timeout
|
|
||||||
const playingRef = useRef(isPlaying);
|
|
||||||
useEffect(() => {
|
|
||||||
playingRef.current = isPlaying;
|
|
||||||
}, [isPlaying]);
|
|
||||||
|
|
||||||
// Clean up timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (playTimeoutRef.current) {
|
|
||||||
clearTimeout(playTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||||
const { previousItem, nextItem } = usePlaybackManager({
|
const { previousItem, nextItem } = usePlaybackManager({
|
||||||
item,
|
item,
|
||||||
@@ -329,125 +300,27 @@ export const Controls: FC<Props> = ({
|
|||||||
subtitleIndex: string;
|
subtitleIndex: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Fetch all segments for the current item
|
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||||
const { data: segments } = useSegments(
|
item.Id!,
|
||||||
item.Id ?? "",
|
currentTime,
|
||||||
|
seek,
|
||||||
|
play,
|
||||||
offline,
|
offline,
|
||||||
downloadedFiles,
|
|
||||||
api,
|
api,
|
||||||
|
downloadedFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert milliseconds to seconds for segment comparison
|
const { showSkipCreditButton, skipCredit, hasContentAfterCredits } =
|
||||||
const currentTimeSeconds = msToSeconds(currentTime);
|
useCreditSkipper(
|
||||||
const maxSeconds = maxMs ? msToSeconds(maxMs) : undefined;
|
item.Id!,
|
||||||
|
currentTime,
|
||||||
// Wrapper to convert segment skip from seconds to milliseconds
|
seek,
|
||||||
// Includes 200ms delay to allow seek operation to complete before resuming playback
|
play,
|
||||||
const seekMs = useCallback(
|
offline,
|
||||||
(timeInSeconds: number) => {
|
api,
|
||||||
// Cancel any pending play call to avoid race conditions
|
downloadedFiles,
|
||||||
if (playTimeoutRef.current) {
|
maxMs,
|
||||||
clearTimeout(playTimeoutRef.current);
|
);
|
||||||
}
|
|
||||||
seek(timeInSeconds * 1000);
|
|
||||||
// Brief delay ensures the seek operation completes before resuming playback
|
|
||||||
// Without this, playback may resume from the old position
|
|
||||||
// Read latest isPlaying from ref to avoid stale closure
|
|
||||||
playTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (playingRef.current) {
|
|
||||||
play();
|
|
||||||
}
|
|
||||||
playTimeoutRef.current = null;
|
|
||||||
}, 200);
|
|
||||||
},
|
|
||||||
[seek, play],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use unified segment skipper for all segment types
|
|
||||||
const introSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.introSegments || [],
|
|
||||||
segmentType: "Intro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekMs,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const outroSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.creditSegments || [],
|
|
||||||
segmentType: "Outro",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
totalDuration: maxSeconds,
|
|
||||||
seek: seekMs,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const recapSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.recapSegments || [],
|
|
||||||
segmentType: "Recap",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekMs,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const commercialSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.commercialSegments || [],
|
|
||||||
segmentType: "Commercial",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekMs,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
const previewSkipper = useSegmentSkipper({
|
|
||||||
segments: segments?.previewSegments || [],
|
|
||||||
segmentType: "Preview",
|
|
||||||
currentTime: currentTimeSeconds,
|
|
||||||
seek: seekMs,
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine which segment button to show (priority order)
|
|
||||||
// Commercial > Recap > Intro > Preview > Outro
|
|
||||||
const activeSegment = useMemo(() => {
|
|
||||||
if (commercialSkipper.currentSegment)
|
|
||||||
return { type: "Commercial", ...commercialSkipper };
|
|
||||||
if (recapSkipper.currentSegment) return { type: "Recap", ...recapSkipper };
|
|
||||||
if (introSkipper.currentSegment) return { type: "Intro", ...introSkipper };
|
|
||||||
if (previewSkipper.currentSegment)
|
|
||||||
return { type: "Preview", ...previewSkipper };
|
|
||||||
if (outroSkipper.currentSegment) return { type: "Outro", ...outroSkipper };
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
commercialSkipper.currentSegment,
|
|
||||||
recapSkipper.currentSegment,
|
|
||||||
introSkipper.currentSegment,
|
|
||||||
previewSkipper.currentSegment,
|
|
||||||
outroSkipper.currentSegment,
|
|
||||||
commercialSkipper,
|
|
||||||
recapSkipper,
|
|
||||||
introSkipper,
|
|
||||||
previewSkipper,
|
|
||||||
outroSkipper,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Legacy compatibility: map to old variable names
|
|
||||||
const showSkipButton = !!(
|
|
||||||
activeSegment &&
|
|
||||||
["Intro", "Recap", "Commercial", "Preview"].includes(activeSegment.type)
|
|
||||||
);
|
|
||||||
const skipIntro = activeSegment?.skipSegment || noop;
|
|
||||||
const showSkipCreditButton = activeSegment?.type === "Outro";
|
|
||||||
const skipCredit = outroSkipper.skipSegment || noop;
|
|
||||||
const hasContentAfterCredits =
|
|
||||||
outroSkipper.currentSegment && maxSeconds
|
|
||||||
? outroSkipper.currentSegment.endTime < maxSeconds
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Get button text based on segment type using i18n
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const skipButtonText = activeSegment
|
|
||||||
? t(`player.skip_${activeSegment.type.toLowerCase()}`)
|
|
||||||
: t("player.skip_intro");
|
|
||||||
const skipCreditButtonText = t("player.skip_outro");
|
|
||||||
|
|
||||||
const goToItemCommon = useCallback(
|
const goToItemCommon = useCallback(
|
||||||
(item: BaseItemDto) => {
|
(item: BaseItemDto) => {
|
||||||
@@ -661,9 +534,7 @@ export const Controls: FC<Props> = ({
|
|||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
remainingTime={remainingTime}
|
remainingTime={remainingTime}
|
||||||
showSkipButton={showSkipButton}
|
showSkipButton={showSkipButton}
|
||||||
skipButtonText={skipButtonText}
|
|
||||||
showSkipCreditButton={showSkipCreditButton}
|
showSkipCreditButton={showSkipCreditButton}
|
||||||
skipCreditButtonText={skipCreditButtonText}
|
|
||||||
hasContentAfterCredits={hasContentAfterCredits}
|
hasContentAfterCredits={hasContentAfterCredits}
|
||||||
skipIntro={skipIntro}
|
skipIntro={skipIntro}
|
||||||
skipCredit={skipCredit}
|
skipCredit={skipCredit}
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ const formatTranscodeReason = (reason: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||||
({ visible, getTechnicalInfo, playMethod, transcodeReasons }) => {
|
({
|
||||||
|
showControls,
|
||||||
|
visible,
|
||||||
|
getTechnicalInfo,
|
||||||
|
playMethod,
|
||||||
|
transcodeReasons,
|
||||||
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
|
|||||||
64
hooks/useAdjacentEpisodes.ts
Normal file
64
hooks/useAdjacentEpisodes.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
|
interface AdjacentEpisodesProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { data: adjacentItems } = useQuery({
|
||||||
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||||
|
if (!api || !item || !item.SeriesId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
|
adjacentTo: item.Id,
|
||||||
|
limit: 3,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "ParentId"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!item?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
(item?.Type === "Episode" || item?.Type === "Audio"),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousItem = useMemo(() => {
|
||||||
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[0];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
|
const nextItem = useMemo(() => {
|
||||||
|
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjacentItems.length === 2) {
|
||||||
|
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacentItems[2];
|
||||||
|
}, [adjacentItems, item]);
|
||||||
|
|
||||||
|
return { previousItem, nextItem };
|
||||||
|
};
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Hook
|
|
||||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
|
||||||
* Architecture allows for future protocol integrations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
CastState,
|
|
||||||
useCastDevice,
|
|
||||||
useCastState,
|
|
||||||
useMediaStatus,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import type { CastPlayerState, CastProtocol } from "@/utils/casting/types";
|
|
||||||
import { DEFAULT_CAST_STATE } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified hook for managing casting
|
|
||||||
* Extensible architecture supporting multiple protocols
|
|
||||||
*/
|
|
||||||
export const useCasting = (item: BaseItemDto | null) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
// Chromecast hooks
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const castState = useCastState();
|
|
||||||
const mediaStatus = useMediaStatus();
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [state, setState] = useState<CastPlayerState>(DEFAULT_CAST_STATE);
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const volumeDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const hasReportedStartRef = useRef<string | null>(null); // Track which item we reported start for
|
|
||||||
const stateRef = useRef<CastPlayerState>(DEFAULT_CAST_STATE); // Ref for progress reporting without deps
|
|
||||||
|
|
||||||
// Helper to update both state and ref
|
|
||||||
const updateState = useCallback(
|
|
||||||
(updater: (prev: CastPlayerState) => CastPlayerState) => {
|
|
||||||
setState((prev) => {
|
|
||||||
const next = updater(prev);
|
|
||||||
stateRef.current = next;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Detect which protocol is active - use CastState for reliable detection
|
|
||||||
const chromecastConnected = castState === CastState.CONNECTED;
|
|
||||||
// Future: Add detection for other protocols here
|
|
||||||
|
|
||||||
const activeProtocol: CastProtocol | null = chromecastConnected
|
|
||||||
? "chromecast"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const isConnected = chromecastConnected;
|
|
||||||
|
|
||||||
// Update current device
|
|
||||||
useEffect(() => {
|
|
||||||
if (chromecastConnected && castDevice) {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isConnected: true,
|
|
||||||
protocol: "chromecast",
|
|
||||||
currentDevice: {
|
|
||||||
id: castDevice.deviceId,
|
|
||||||
name: castDevice.friendlyName || castDevice.deviceId,
|
|
||||||
protocol: "chromecast",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isConnected: false,
|
|
||||||
protocol: null,
|
|
||||||
currentDevice: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// Future: Add device detection for other protocols
|
|
||||||
}, [chromecastConnected, castDevice]);
|
|
||||||
|
|
||||||
// Chromecast: Update playback state
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeProtocol === "chromecast" && mediaStatus) {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
isPlaying: mediaStatus.playerState === "playing",
|
|
||||||
progress: (mediaStatus.streamPosition || 0) * 1000,
|
|
||||||
duration: (mediaStatus.mediaInfo?.streamDuration || 0) * 1000,
|
|
||||||
isBuffering: mediaStatus.playerState === "buffering",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mediaStatus, activeProtocol, updateState]);
|
|
||||||
|
|
||||||
// Chromecast: Sync volume from mediaStatus
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeProtocol !== "chromecast") return;
|
|
||||||
|
|
||||||
// Sync from mediaStatus when available
|
|
||||||
if (mediaStatus?.volume !== undefined) {
|
|
||||||
updateState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
volume: mediaStatus.volume,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [mediaStatus?.volume, activeProtocol, updateState]);
|
|
||||||
|
|
||||||
// Progress reporting to Jellyfin (matches native player behavior)
|
|
||||||
// Uses stateRef to read current progress/volume without adding them as deps
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isConnected || !item?.Id || !user?.Id || !api) return;
|
|
||||||
|
|
||||||
const playStateApi = getPlaystateApi(api);
|
|
||||||
|
|
||||||
// Report playback start when media begins (only once per item)
|
|
||||||
// Don't require progress > 0 — playback can legitimately start at position 0
|
|
||||||
const currentState = stateRef.current;
|
|
||||||
const isPlaybackActive =
|
|
||||||
currentState.isPlaying ||
|
|
||||||
mediaStatus?.playerState === "playing" ||
|
|
||||||
currentState.progress > 0;
|
|
||||||
if (hasReportedStartRef.current !== item.Id && isPlaybackActive) {
|
|
||||||
// Set synchronously before async call to prevent race condition duplicates
|
|
||||||
hasReportedStartRef.current = item.Id || null;
|
|
||||||
|
|
||||||
playStateApi
|
|
||||||
.reportPlaybackStart({
|
|
||||||
playbackStartInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: Math.floor(currentState.progress * 10000),
|
|
||||||
PlayMethod:
|
|
||||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
|
||||||
VolumeLevel: Math.floor(currentState.volume * 100),
|
|
||||||
IsMuted: currentState.volume === 0,
|
|
||||||
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
// Revert on failure so it can be retried
|
|
||||||
hasReportedStartRef.current = null;
|
|
||||||
console.error("[useCasting] Failed to report playback start:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportProgress = () => {
|
|
||||||
const s = stateRef.current;
|
|
||||||
// Don't report if no meaningful progress or if buffering
|
|
||||||
if (s.progress <= 0 || s.isBuffering) return;
|
|
||||||
|
|
||||||
const progressMs = Math.floor(s.progress);
|
|
||||||
const progressTicks = progressMs * 10000; // Convert ms to ticks
|
|
||||||
const progressSeconds = Math.floor(progressMs / 1000);
|
|
||||||
|
|
||||||
// When paused, always report to keep server in sync
|
|
||||||
// When playing, skip if progress hasn't changed significantly (less than 3 seconds)
|
|
||||||
if (
|
|
||||||
s.isPlaying &&
|
|
||||||
Math.abs(progressSeconds - lastReportedProgressRef.current) < 3
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastReportedProgressRef.current = progressSeconds;
|
|
||||||
|
|
||||||
playStateApi
|
|
||||||
.reportPlaybackProgress({
|
|
||||||
playbackProgressInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: progressTicks,
|
|
||||||
IsPaused: !s.isPlaying,
|
|
||||||
PlayMethod:
|
|
||||||
activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay",
|
|
||||||
VolumeLevel: Math.floor(s.volume * 100),
|
|
||||||
IsMuted: s.volume === 0,
|
|
||||||
PlaySessionId: mediaStatus?.mediaInfo?.contentId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[useCasting] Failed to report progress:", error);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Report progress on a fixed interval, reading latest state from ref
|
|
||||||
const interval = setInterval(reportProgress, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
item?.Id,
|
|
||||||
user?.Id,
|
|
||||||
isConnected,
|
|
||||||
activeProtocol,
|
|
||||||
mediaStatus?.mediaInfo?.contentId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Play/Pause controls
|
|
||||||
const play = useCallback(async () => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
// Check if there's an active media session
|
|
||||||
if (!client || !mediaStatus?.mediaInfo) {
|
|
||||||
console.warn(
|
|
||||||
"[useCasting] Cannot play - no active media session. Media needs to be loaded first.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await client.play();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[useCasting] Error playing:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Future: Add play control for other protocols
|
|
||||||
}, [client, mediaStatus, activeProtocol]);
|
|
||||||
|
|
||||||
const pause = useCallback(async () => {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
try {
|
|
||||||
await client?.pause();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[useCasting] Error pausing:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Future: Add pause control for other protocols
|
|
||||||
}, [client, activeProtocol]);
|
|
||||||
|
|
||||||
const togglePlayPause = useCallback(async () => {
|
|
||||||
if (state.isPlaying) {
|
|
||||||
await pause();
|
|
||||||
} else {
|
|
||||||
await play();
|
|
||||||
}
|
|
||||||
}, [state.isPlaying, play, pause]);
|
|
||||||
|
|
||||||
// Seek controls
|
|
||||||
const seek = useCallback(
|
|
||||||
async (positionMs: number) => {
|
|
||||||
// Validate position
|
|
||||||
if (positionMs < 0 || !Number.isFinite(positionMs)) {
|
|
||||||
console.error("[useCasting] Invalid seek position (ms):", positionMs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const positionSeconds = positionMs / 1000;
|
|
||||||
|
|
||||||
// Additional validation for Chromecast
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
// state.duration is in ms, positionSeconds is in seconds - compare in same unit
|
|
||||||
// Only clamp when duration is known (> 0) to avoid forcing seeks to 0
|
|
||||||
const durationSeconds = state.duration / 1000;
|
|
||||||
if (durationSeconds > 0 && positionSeconds > durationSeconds) {
|
|
||||||
console.warn(
|
|
||||||
"[useCasting] Seek position exceeds duration, clamping:",
|
|
||||||
positionSeconds,
|
|
||||||
"->",
|
|
||||||
durationSeconds,
|
|
||||||
);
|
|
||||||
await client?.seek({ position: durationSeconds });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await client?.seek({ position: positionSeconds });
|
|
||||||
}
|
|
||||||
// Future: Add seek control for other protocols
|
|
||||||
},
|
|
||||||
[client, activeProtocol, state.duration],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipForward = useCallback(
|
|
||||||
async (seconds = 10) => {
|
|
||||||
const newPosition = state.progress + seconds * 1000;
|
|
||||||
await seek(Math.min(newPosition, state.duration));
|
|
||||||
},
|
|
||||||
[state.progress, state.duration, seek],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipBackward = useCallback(
|
|
||||||
async (seconds = 10) => {
|
|
||||||
const newPosition = state.progress - seconds * 1000;
|
|
||||||
await seek(Math.max(newPosition, 0));
|
|
||||||
},
|
|
||||||
[state.progress, seek],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stop and disconnect
|
|
||||||
const stop = useCallback(
|
|
||||||
async (onStopComplete?: () => void) => {
|
|
||||||
try {
|
|
||||||
if (activeProtocol === "chromecast") {
|
|
||||||
await client?.stop();
|
|
||||||
}
|
|
||||||
// Future: Add stop control for other protocols
|
|
||||||
|
|
||||||
// Report stop to Jellyfin
|
|
||||||
if (api && item?.Id && user?.Id) {
|
|
||||||
const playStateApi = getPlaystateApi(api);
|
|
||||||
await playStateApi.reportPlaybackStopped({
|
|
||||||
playbackStopInfo: {
|
|
||||||
ItemId: item.Id,
|
|
||||||
PositionTicks: stateRef.current.progress * 10000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[useCasting] Error during stop:", error);
|
|
||||||
} finally {
|
|
||||||
hasReportedStartRef.current = null;
|
|
||||||
setState(DEFAULT_CAST_STATE);
|
|
||||||
stateRef.current = DEFAULT_CAST_STATE;
|
|
||||||
|
|
||||||
// Call callback after stop completes (e.g., to navigate away)
|
|
||||||
if (onStopComplete) {
|
|
||||||
onStopComplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[client, api, item?.Id, user?.Id, activeProtocol],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Volume control (debounced to reduce API calls)
|
|
||||||
const setVolume = useCallback(
|
|
||||||
(volume: number) => {
|
|
||||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
|
||||||
|
|
||||||
// Update UI immediately
|
|
||||||
updateState((prev) => ({ ...prev, volume: clampedVolume }));
|
|
||||||
|
|
||||||
// Debounce API call
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
volumeDebounceRef.current = setTimeout(async () => {
|
|
||||||
if (activeProtocol === "chromecast" && client && isConnected) {
|
|
||||||
// Use setStreamVolume for media stream volume (0.0 - 1.0)
|
|
||||||
// Physical volume buttons are handled automatically by the framework
|
|
||||||
await client.setStreamVolume(clampedVolume).catch(() => {
|
|
||||||
// Ignore errors - session might have ended
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Future: Add volume control for other protocols
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
[client, activeProtocol, isConnected],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (volumeDebounceRef.current) {
|
|
||||||
clearTimeout(volumeDebounceRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isConnected,
|
|
||||||
protocol: activeProtocol,
|
|
||||||
isPlaying: state.isPlaying,
|
|
||||||
isBuffering: state.isBuffering,
|
|
||||||
currentItem: item,
|
|
||||||
currentDevice: state.currentDevice,
|
|
||||||
progress: state.progress,
|
|
||||||
duration: state.duration,
|
|
||||||
volume: state.volume,
|
|
||||||
|
|
||||||
// Availability - derived from actual cast state
|
|
||||||
isChromecastAvailable:
|
|
||||||
castState === CastState.CONNECTED ||
|
|
||||||
castState === CastState.CONNECTING ||
|
|
||||||
castState === CastState.NOT_CONNECTED,
|
|
||||||
|
|
||||||
// Raw clients (for advanced operations)
|
|
||||||
remoteMediaClient: client,
|
|
||||||
|
|
||||||
// Controls
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
togglePlayPause,
|
|
||||||
seek,
|
|
||||||
skipForward,
|
|
||||||
skipBackward,
|
|
||||||
stop,
|
|
||||||
setVolume,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
109
hooks/useCreditSkipper.ts
Normal file
109
hooks/useCreditSkipper.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping credits in a media player.
|
||||||
|
* The player reports time values in milliseconds.
|
||||||
|
*/
|
||||||
|
export const useCreditSkipper = (
|
||||||
|
itemId: string,
|
||||||
|
currentTime: number,
|
||||||
|
seek: (ms: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isOffline = false,
|
||||||
|
api: Api | null = null,
|
||||||
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
|
totalDuration?: number,
|
||||||
|
) => {
|
||||||
|
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
// Convert ms to seconds for comparison with timestamps
|
||||||
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
|
|
||||||
|
const totalDurationInSeconds =
|
||||||
|
totalDuration != null ? msToSeconds(totalDuration) : undefined;
|
||||||
|
|
||||||
|
// Regular function (not useCallback) to match useIntroSkipper pattern
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: segments } = useSegments(
|
||||||
|
itemId,
|
||||||
|
isOffline,
|
||||||
|
downloadedFiles,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const creditTimestamps = segments?.creditSegments?.[0];
|
||||||
|
|
||||||
|
// Determine if there's content after credits (credits don't extend to video end)
|
||||||
|
// Use a 5-second buffer to account for timing discrepancies
|
||||||
|
const hasContentAfterCredits = (() => {
|
||||||
|
if (
|
||||||
|
!creditTimestamps ||
|
||||||
|
totalDurationInSeconds == null ||
|
||||||
|
!Number.isFinite(totalDurationInSeconds)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const creditsEndToVideoEnd =
|
||||||
|
totalDurationInSeconds - creditTimestamps.endTime;
|
||||||
|
// If credits end more than 5 seconds before video ends, there's content after
|
||||||
|
return creditsEndToVideoEnd > 5;
|
||||||
|
})();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creditTimestamps) {
|
||||||
|
const shouldShow =
|
||||||
|
currentTimeSeconds > creditTimestamps.startTime &&
|
||||||
|
currentTimeSeconds < creditTimestamps.endTime;
|
||||||
|
|
||||||
|
setShowSkipCreditButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
// Reset button state when no credit timestamps exist
|
||||||
|
if (showSkipCreditButton) {
|
||||||
|
setShowSkipCreditButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [creditTimestamps, currentTimeSeconds, showSkipCreditButton]);
|
||||||
|
|
||||||
|
const skipCredit = useCallback(() => {
|
||||||
|
if (!creditTimestamps) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lightHapticFeedback();
|
||||||
|
|
||||||
|
// Calculate the target seek position
|
||||||
|
let seekTarget = creditTimestamps.endTime;
|
||||||
|
|
||||||
|
// If we have total duration, ensure we don't seek past the end of the video.
|
||||||
|
// Some media sources report credit end times that exceed the actual video duration,
|
||||||
|
// which causes the player to pause/stop when seeking past the end.
|
||||||
|
// Leave a small buffer (2 seconds) to trigger the natural end-of-video flow
|
||||||
|
// (next episode countdown, etc.) instead of an abrupt pause.
|
||||||
|
if (totalDurationInSeconds && seekTarget >= totalDurationInSeconds) {
|
||||||
|
seekTarget = Math.max(0, totalDurationInSeconds - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappedSeek(seekTarget);
|
||||||
|
setTimeout(() => {
|
||||||
|
play();
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CREDIT_SKIPPER] Error skipping credit", error);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
creditTimestamps,
|
||||||
|
lightHapticFeedback,
|
||||||
|
wrappedSeek,
|
||||||
|
play,
|
||||||
|
totalDurationInSeconds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { showSkipCreditButton, skipCredit, hasContentAfterCredits };
|
||||||
|
};
|
||||||
68
hooks/useIntroSkipper.ts
Normal file
68
hooks/useIntroSkipper.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Api } from "@jellyfin/sdk";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { useSegments } from "@/utils/segments";
|
||||||
|
import { msToSeconds, secondsToMs } from "@/utils/time";
|
||||||
|
import { useHaptic } from "./useHaptic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to handle skipping intros in a media player.
|
||||||
|
* MPV player uses milliseconds for time.
|
||||||
|
*
|
||||||
|
* @param {number} currentTime - The current playback time in milliseconds.
|
||||||
|
*/
|
||||||
|
export const useIntroSkipper = (
|
||||||
|
itemId: string,
|
||||||
|
currentTime: number,
|
||||||
|
seek: (ms: number) => void,
|
||||||
|
play: () => void,
|
||||||
|
isOffline = false,
|
||||||
|
api: Api | null = null,
|
||||||
|
downloadedFiles: DownloadedItem[] | undefined = undefined,
|
||||||
|
) => {
|
||||||
|
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||||
|
// Convert ms to seconds for comparison with timestamps
|
||||||
|
const currentTimeSeconds = msToSeconds(currentTime);
|
||||||
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
|
const wrappedSeek = (seconds: number) => {
|
||||||
|
seek(secondsToMs(seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: segments } = useSegments(
|
||||||
|
itemId,
|
||||||
|
isOffline,
|
||||||
|
downloadedFiles,
|
||||||
|
api,
|
||||||
|
);
|
||||||
|
const introTimestamps = segments?.introSegments?.[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (introTimestamps) {
|
||||||
|
const shouldShow =
|
||||||
|
currentTimeSeconds > introTimestamps.startTime &&
|
||||||
|
currentTimeSeconds < introTimestamps.endTime;
|
||||||
|
|
||||||
|
setShowSkipButton(shouldShow);
|
||||||
|
} else {
|
||||||
|
if (showSkipButton) {
|
||||||
|
setShowSkipButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [introTimestamps, currentTimeSeconds, showSkipButton]);
|
||||||
|
|
||||||
|
const skipIntro = useCallback(() => {
|
||||||
|
if (!introTimestamps) return;
|
||||||
|
try {
|
||||||
|
lightHapticFeedback();
|
||||||
|
wrappedSeek(introTimestamps.endTime);
|
||||||
|
setTimeout(() => {
|
||||||
|
play();
|
||||||
|
}, 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[INTRO_SKIPPER] Error skipping intro", error);
|
||||||
|
}
|
||||||
|
}, [introTimestamps, lightHapticFeedback, wrappedSeek, play]);
|
||||||
|
|
||||||
|
return { showSkipButton, skipIntro };
|
||||||
|
};
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { MediaTimeSegment } from "@/providers/Downloads/types";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { useHaptic } from "./useHaptic";
|
|
||||||
|
|
||||||
type SegmentType = "Intro" | "Outro" | "Recap" | "Commercial" | "Preview";
|
|
||||||
|
|
||||||
interface UseSegmentSkipperProps {
|
|
||||||
segments: MediaTimeSegment[];
|
|
||||||
segmentType: SegmentType;
|
|
||||||
currentTime: number;
|
|
||||||
totalDuration?: number;
|
|
||||||
seek: (time: number) => void;
|
|
||||||
isPaused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseSegmentSkipperReturn {
|
|
||||||
currentSegment: MediaTimeSegment | null;
|
|
||||||
skipSegment: (notifyOrUseHaptics?: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic hook to handle all media segment types (intro, outro, recap, commercial, preview)
|
|
||||||
* Supports three modes: 'none' (disabled), 'ask' (show button), 'auto' (auto-skip)
|
|
||||||
*/
|
|
||||||
export const useSegmentSkipper = ({
|
|
||||||
segments,
|
|
||||||
segmentType,
|
|
||||||
currentTime,
|
|
||||||
totalDuration,
|
|
||||||
seek,
|
|
||||||
isPaused,
|
|
||||||
}: UseSegmentSkipperProps): UseSegmentSkipperReturn => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const haptic = useHaptic();
|
|
||||||
const autoSkipTriggeredRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Get skip mode based on segment type
|
|
||||||
const skipMode = (() => {
|
|
||||||
switch (segmentType) {
|
|
||||||
case "Intro":
|
|
||||||
return settings.skipIntro;
|
|
||||||
case "Outro":
|
|
||||||
return settings.skipOutro;
|
|
||||||
case "Recap":
|
|
||||||
return settings.skipRecap;
|
|
||||||
case "Commercial":
|
|
||||||
return settings.skipCommercial;
|
|
||||||
case "Preview":
|
|
||||||
return settings.skipPreview;
|
|
||||||
default:
|
|
||||||
return "none";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Find current segment
|
|
||||||
const currentSegment =
|
|
||||||
segments.find(
|
|
||||||
(segment) =>
|
|
||||||
currentTime >= segment.startTime && currentTime < segment.endTime,
|
|
||||||
) || null;
|
|
||||||
|
|
||||||
// Skip function with optional haptic feedback
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
(notifyOrUseHaptics = true) => {
|
|
||||||
if (!currentSegment || skipMode === "none") return;
|
|
||||||
|
|
||||||
// For Outro segments, prevent seeking past the end
|
|
||||||
if (
|
|
||||||
segmentType === "Outro" &&
|
|
||||||
totalDuration != null &&
|
|
||||||
Number.isFinite(totalDuration)
|
|
||||||
) {
|
|
||||||
const seekTime = Math.min(currentSegment.endTime, totalDuration);
|
|
||||||
seek(seekTime);
|
|
||||||
} else {
|
|
||||||
seek(currentSegment.endTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only trigger haptic feedback if explicitly requested (manual skip)
|
|
||||||
if (notifyOrUseHaptics) {
|
|
||||||
haptic();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentSegment, segmentType, totalDuration, seek, haptic, skipMode],
|
|
||||||
);
|
|
||||||
// Auto-skip logic when mode is 'auto'
|
|
||||||
useEffect(() => {
|
|
||||||
if (skipMode !== "auto" || isPaused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track segment identity to avoid re-triggering on pause/unpause
|
|
||||||
const segmentId = currentSegment
|
|
||||||
? `${currentSegment.startTime}-${currentSegment.endTime}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (currentSegment && autoSkipTriggeredRef.current !== segmentId) {
|
|
||||||
autoSkipTriggeredRef.current = segmentId;
|
|
||||||
skipSegment(false); // Don't trigger haptics for auto-skip
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSegment) {
|
|
||||||
autoSkipTriggeredRef.current = null;
|
|
||||||
}
|
|
||||||
}, [currentSegment, skipMode, isPaused, skipSegment]);
|
|
||||||
|
|
||||||
// Return null segment if skip mode is 'none'
|
|
||||||
return {
|
|
||||||
currentSegment: skipMode === "none" ? null : currentSegment,
|
|
||||||
skipSegment,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -17,24 +17,20 @@ interface TrickplayUrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hook to handle trickplay logic for a given item. */
|
/** Hook to handle trickplay logic for a given item. */
|
||||||
export const useTrickplay = (item: BaseItemDto | null) => {
|
export const useTrickplay = (item: BaseItemDto) => {
|
||||||
const { getDownloadedItemById } = useDownload();
|
const { getDownloadedItemById } = useDownload();
|
||||||
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
|
||||||
const lastCalculationTime = useRef(0);
|
const lastCalculationTime = useRef(0);
|
||||||
const throttleDelay = 200;
|
const throttleDelay = 200;
|
||||||
const isOffline = useGlobalSearchParams().offline === "true";
|
const isOffline = useGlobalSearchParams().offline === "true";
|
||||||
const trickplayInfo = useMemo(
|
const trickplayInfo = useMemo(() => getTrickplayInfo(item), [item]);
|
||||||
() => (item ? getTrickplayInfo(item) : null),
|
|
||||||
[item],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Generates the trickplay URL for the given item and sheet index.
|
/** Generates the trickplay URL for the given item and sheet index.
|
||||||
* We change between offline and online trickplay URLs depending on the state of the app. */
|
* We change between offline and online trickplay URLs depending on the state of the app. */
|
||||||
const getTrickplayUrl = useCallback(
|
const getTrickplayUrl = useCallback(
|
||||||
(item: BaseItemDto, sheetIndex: number) => {
|
(item: BaseItemDto, sheetIndex: number) => {
|
||||||
if (!item.Id) return null;
|
|
||||||
// If we are offline, we can use the downloaded item's trickplay data path
|
// If we are offline, we can use the downloaded item's trickplay data path
|
||||||
const downloadedItem = getDownloadedItemById(item.Id);
|
const downloadedItem = getDownloadedItemById(item.Id!);
|
||||||
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
if (isOffline && downloadedItem?.trickPlayData?.path) {
|
||||||
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
return `${downloadedItem.trickPlayData.path}${sheetIndex}.jpg`;
|
||||||
}
|
}
|
||||||
@@ -49,7 +45,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
!trickplayInfo ||
|
!trickplayInfo ||
|
||||||
!item?.Id ||
|
!item.Id ||
|
||||||
now - lastCalculationTime.current < throttleDelay
|
now - lastCalculationTime.current < throttleDelay
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -66,7 +62,7 @@ export const useTrickplay = (item: BaseItemDto | null) => {
|
|||||||
|
|
||||||
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
/** Prefetches all the trickplay images for the item, limiting concurrency to avoid I/O spikes. */
|
||||||
const prefetchAllTrickplayImages = useCallback(async () => {
|
const prefetchAllTrickplayImages = useCallback(async () => {
|
||||||
if (!trickplayInfo || !item?.Id) return;
|
if (!trickplayInfo || !item.Id) return;
|
||||||
const maxConcurrent = 4;
|
const maxConcurrent = 4;
|
||||||
const total = trickplayInfo.totalImageSheets;
|
const total = trickplayInfo.totalImageSheets;
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
|
|||||||
Binary file not shown.
@@ -1,13 +1,10 @@
|
|||||||
package expo.modules.mpvplayer
|
package expo.modules.mpvplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.AssetManager
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MPV renderer that wraps libmpv for video playback.
|
* MPV renderer that wraps libmpv for video playback.
|
||||||
@@ -29,7 +26,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Delegate {
|
interface Delegate {
|
||||||
fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double)
|
fun onPositionChanged(position: Double, duration: Double)
|
||||||
fun onPauseChanged(isPaused: Boolean)
|
fun onPauseChanged(isPaused: Boolean)
|
||||||
fun onLoadingChanged(isLoading: Boolean)
|
fun onLoadingChanged(isLoading: Boolean)
|
||||||
fun onReadyToSeek()
|
fun onReadyToSeek()
|
||||||
@@ -49,7 +46,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
// Cached state
|
// Cached state
|
||||||
private var cachedPosition: Double = 0.0
|
private var cachedPosition: Double = 0.0
|
||||||
private var cachedDuration: Double = 0.0
|
private var cachedDuration: Double = 0.0
|
||||||
private var cachedCacheSeconds: Double = 0.0
|
|
||||||
private var _isPaused: Boolean = true
|
private var _isPaused: Boolean = true
|
||||||
private var _isLoading: Boolean = false
|
private var _isLoading: Boolean = false
|
||||||
private var _playbackSpeed: Double = 1.0
|
private var _playbackSpeed: Double = 1.0
|
||||||
@@ -105,52 +101,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
MPVLib.create(context)
|
MPVLib.create(context)
|
||||||
MPVLib.addObserver(this)
|
MPVLib.addObserver(this)
|
||||||
|
|
||||||
/**
|
|
||||||
* Create mpv config directory and copy font files to ensure SubRip subtitles load properly on Android.
|
|
||||||
*
|
|
||||||
* Technical Background:
|
|
||||||
* ====================
|
|
||||||
* On Android, mpv requires access to a font file to render text-based subtitles, particularly SubRip (.srt)
|
|
||||||
* format subtitles. Without an available font in the config directory, mpv will fail to display subtitles
|
|
||||||
* even when subtitle tracks are properly detected and loaded.
|
|
||||||
*
|
|
||||||
* Why This Is Necessary:
|
|
||||||
* =====================
|
|
||||||
* 1. Android's font system is isolated from native libraries like mpv. While Android has system fonts,
|
|
||||||
* mpv cannot access them directly due to sandboxing and library isolation.
|
|
||||||
*
|
|
||||||
* 2. SubRip subtitles require a font to render text overlay on video. When no font is available in the
|
|
||||||
* configured directory, mpv either:
|
|
||||||
* - Fails silently (subtitles don't appear)
|
|
||||||
* - Falls back to a default font that may not support the required character set
|
|
||||||
* - Crashes or produces rendering errors
|
|
||||||
*
|
|
||||||
* 3. By placing a font file (font.ttf) in mpv's config directory and setting that directory via
|
|
||||||
* MPVLib.setOptionString("config-dir", ...), we ensure mpv has a known, accessible font source.
|
|
||||||
*
|
|
||||||
* Reference:
|
|
||||||
* =========
|
|
||||||
* This workaround is documented in the mpv-android project:
|
|
||||||
* https://github.com/mpv-android/mpv-android/issues/96
|
|
||||||
*
|
|
||||||
* The issue discusses that without a font in the config directory, SubRip subtitles fail to load
|
|
||||||
* properly on Android, and the solution is to copy a font file to a known location that mpv can access.
|
|
||||||
*/
|
|
||||||
// Create mpv config directory and copy font files
|
|
||||||
val mpvDir = File(context.getExternalFilesDir(null) ?: context.filesDir, "mpv")
|
|
||||||
//Log.i(TAG, "mpv config dir: $mpvDir")
|
|
||||||
if (!mpvDir.exists()) mpvDir.mkdirs()
|
|
||||||
// This needs to be named `subfont.ttf` else it won't work
|
|
||||||
arrayOf("subfont.ttf").forEach { fileName ->
|
|
||||||
val file = File(mpvDir, fileName)
|
|
||||||
if (file.exists()) return@forEach
|
|
||||||
context.assets
|
|
||||||
.open(fileName, AssetManager.ACCESS_STREAMING)
|
|
||||||
.copyTo(FileOutputStream(file))
|
|
||||||
}
|
|
||||||
MPVLib.setOptionString("config", "yes")
|
|
||||||
MPVLib.setOptionString("config-dir", mpvDir.path)
|
|
||||||
|
|
||||||
// Configure mpv options before initialization (based on Findroid)
|
// Configure mpv options before initialization (based on Findroid)
|
||||||
MPVLib.setOptionString("vo", "gpu")
|
MPVLib.setOptionString("vo", "gpu")
|
||||||
MPVLib.setOptionString("gpu-context", "android")
|
MPVLib.setOptionString("gpu-context", "android")
|
||||||
@@ -174,7 +124,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
MPVLib.setOptionString("hr-seek-framedrop", "yes")
|
||||||
|
|
||||||
// Subtitle settings
|
// Subtitle settings
|
||||||
MPVLib.setOptionString("sub-scale-with-window", "no")
|
MPVLib.setOptionString("sub-scale-with-window", "yes")
|
||||||
MPVLib.setOptionString("sub-use-margins", "no")
|
MPVLib.setOptionString("sub-use-margins", "no")
|
||||||
MPVLib.setOptionString("subs-match-os-language", "yes")
|
MPVLib.setOptionString("subs-match-os-language", "yes")
|
||||||
MPVLib.setOptionString("subs-fallback", "yes")
|
MPVLib.setOptionString("subs-fallback", "yes")
|
||||||
@@ -333,7 +283,6 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
MPVLib.observeProperty("pause", MPV_FORMAT_FLAG)
|
||||||
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("track-list/count", MPV_FORMAT_INT64)
|
||||||
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
MPVLib.observeProperty("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
|
||||||
// Video dimensions for PiP aspect ratio
|
// Video dimensions for PiP aspect ratio
|
||||||
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("video-params/w", MPV_FORMAT_INT64)
|
||||||
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
MPVLib.observeProperty("video-params/h", MPV_FORMAT_INT64)
|
||||||
@@ -612,7 +561,7 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
when (property) {
|
when (property) {
|
||||||
"duration" -> {
|
"duration" -> {
|
||||||
cachedDuration = value
|
cachedDuration = value
|
||||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
|
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
|
||||||
}
|
}
|
||||||
"time-pos" -> {
|
"time-pos" -> {
|
||||||
cachedPosition = value
|
cachedPosition = value
|
||||||
@@ -621,12 +570,9 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
|||||||
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
|
val shouldUpdate = _isSeeking || (now - lastProgressUpdateTime >= 1000)
|
||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
lastProgressUpdateTime = now
|
lastProgressUpdateTime = now
|
||||||
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration, cachedCacheSeconds) }
|
mainHandler.post { delegate?.onPositionChanged(cachedPosition, cachedDuration) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"demuxer-cache-duration" -> {
|
|
||||||
cachedCacheSeconds = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
|
|
||||||
// MARK: - MPVLayerRenderer.Delegate
|
// MARK: - MPVLayerRenderer.Delegate
|
||||||
|
|
||||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
override fun onPositionChanged(position: Double, duration: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
@@ -319,8 +319,7 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
onProgress(mapOf(
|
onProgress(mapOf(
|
||||||
"position" to position,
|
"position" to position,
|
||||||
"duration" to duration,
|
"duration" to duration,
|
||||||
"progress" to if (duration > 0) position / duration else 0.0,
|
"progress" to if (duration > 0) position / duration else 0.0
|
||||||
"cacheSeconds" to cacheSeconds
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import CoreVideo
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
protocol MPVLayerRendererDelegate: AnyObject {
|
protocol MPVLayerRendererDelegate: AnyObject {
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double)
|
func renderer(_ renderer: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangePause isPaused: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didChangeLoading isLoading: Bool)
|
||||||
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
func renderer(_ renderer: MPVLayerRenderer, didBecomeReadyToSeek: Bool)
|
||||||
@@ -44,7 +44,6 @@ final class MPVLayerRenderer {
|
|||||||
// Thread-safe state for playback
|
// Thread-safe state for playback
|
||||||
private var _cachedDuration: Double = 0
|
private var _cachedDuration: Double = 0
|
||||||
private var _cachedPosition: Double = 0
|
private var _cachedPosition: Double = 0
|
||||||
private var _cachedCacheSeconds: Double = 0
|
|
||||||
private var _isPaused: Bool = true
|
private var _isPaused: Bool = true
|
||||||
private var _playbackSpeed: Double = 1.0
|
private var _playbackSpeed: Double = 1.0
|
||||||
private var _isLoading: Bool = false
|
private var _isLoading: Bool = false
|
||||||
@@ -76,10 +75,6 @@ final class MPVLayerRenderer {
|
|||||||
get { stateQueue.sync { _cachedPosition } }
|
get { stateQueue.sync { _cachedPosition } }
|
||||||
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
|
set { stateQueue.async(flags: .barrier) { self._cachedPosition = newValue } }
|
||||||
}
|
}
|
||||||
private var cachedCacheSeconds: Double {
|
|
||||||
get { stateQueue.sync { _cachedCacheSeconds } }
|
|
||||||
set { stateQueue.async(flags: .barrier) { self._cachedCacheSeconds = newValue } }
|
|
||||||
}
|
|
||||||
private var isPaused: Bool {
|
private var isPaused: Bool {
|
||||||
get { stateQueue.sync { _isPaused } }
|
get { stateQueue.sync { _isPaused } }
|
||||||
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
|
set { stateQueue.async(flags: .barrier) { self._isPaused = newValue } }
|
||||||
@@ -169,7 +164,6 @@ final class MPVLayerRenderer {
|
|||||||
|
|
||||||
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
// Enable composite OSD mode - renders subtitles directly onto video frames using GPU
|
||||||
// This is better for PiP as subtitles are baked into the video
|
// This is better for PiP as subtitles are baked into the video
|
||||||
// NOTE: Must be set BEFORE the #if targetEnvironment check or tvOS will freeze on player exit
|
|
||||||
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
checkError(mpv_set_option_string(handle, "avfoundation-composite-osd", "yes"))
|
||||||
|
|
||||||
// Hardware decoding with VideoToolbox
|
// Hardware decoding with VideoToolbox
|
||||||
@@ -346,8 +340,7 @@ final class MPVLayerRenderer {
|
|||||||
("time-pos", MPV_FORMAT_DOUBLE),
|
("time-pos", MPV_FORMAT_DOUBLE),
|
||||||
("pause", MPV_FORMAT_FLAG),
|
("pause", MPV_FORMAT_FLAG),
|
||||||
("track-list/count", MPV_FORMAT_INT64),
|
("track-list/count", MPV_FORMAT_INT64),
|
||||||
("paused-for-cache", MPV_FORMAT_FLAG),
|
("paused-for-cache", MPV_FORMAT_FLAG)
|
||||||
("demuxer-cache-duration", MPV_FORMAT_DOUBLE)
|
|
||||||
]
|
]
|
||||||
for (name, format) in properties {
|
for (name, format) in properties {
|
||||||
mpv_observe_property(handle, 0, name, format)
|
mpv_observe_property(handle, 0, name, format)
|
||||||
@@ -491,7 +484,7 @@ final class MPVLayerRenderer {
|
|||||||
cachedDuration = value
|
cachedDuration = value
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
|
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "time-pos":
|
case "time-pos":
|
||||||
@@ -506,16 +499,10 @@ final class MPVLayerRenderer {
|
|||||||
lastProgressUpdateTime = now
|
lastProgressUpdateTime = now
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration, cacheSeconds: self.cachedCacheSeconds)
|
self.delegate?.renderer(self, didUpdatePosition: self.cachedPosition, duration: self.cachedDuration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "demuxer-cache-duration":
|
|
||||||
var value = Double(0)
|
|
||||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_DOUBLE, value: &value)
|
|
||||||
if status >= 0 {
|
|
||||||
cachedCacheSeconds = value
|
|
||||||
}
|
|
||||||
case "pause":
|
case "pause":
|
||||||
var flag: Int32 = 0
|
var flag: Int32 = 0
|
||||||
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
let status = getProperty(handle: handle, name: name, format: MPV_FORMAT_FLAG, value: &flag)
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ class MpvPlayerView: ExpoView {
|
|||||||
// MARK: - MPVLayerRendererDelegate
|
// MARK: - MPVLayerRendererDelegate
|
||||||
|
|
||||||
extension MpvPlayerView: MPVLayerRendererDelegate {
|
extension MpvPlayerView: MPVLayerRendererDelegate {
|
||||||
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double, cacheSeconds: Double) {
|
func renderer(_: MPVLayerRenderer, didUpdatePosition position: Double, duration: Double) {
|
||||||
cachedPosition = position
|
cachedPosition = position
|
||||||
cachedDuration = duration
|
cachedDuration = duration
|
||||||
|
|
||||||
@@ -313,7 +313,6 @@ extension MpvPlayerView: MPVLayerRendererDelegate {
|
|||||||
"position": position,
|
"position": position,
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
"progress": duration > 0 ? position / duration : 0,
|
"progress": duration > 0 ? position / duration : 0,
|
||||||
"cacheSeconds": cacheSeconds,
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export type OnProgressEventPayload = {
|
|||||||
position: number;
|
position: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
/** Seconds of video buffered ahead of current position */
|
|
||||||
cacheSeconds: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OnErrorEventPayload = {
|
export type OnErrorEventPayload = {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
"@tanstack/query-sync-storage-persister": "^5.90.18",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.90.20",
|
"@tanstack/react-query": "5.90.17",
|
||||||
"@tanstack/react-query-persist-client": "^5.90.18",
|
"@tanstack/react-query-persist-client": "^5.90.18",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~54.0.31",
|
"expo": "~54.0.31",
|
||||||
@@ -72,12 +72,12 @@
|
|||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
"i18next": "^25.0.0",
|
"i18next": "^25.0.0",
|
||||||
"jotai": "2.16.2",
|
"jotai": "2.16.2",
|
||||||
"lodash": "4.17.23",
|
"lodash": "4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "16.5.4",
|
"react-i18next": "16.5.3",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.28.6",
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
"@react-native-community/cli": "20.1.1",
|
"@react-native-community/cli": "20.1.0",
|
||||||
"@react-native-tvos/config-tv": "0.1.4",
|
"@react-native-tvos/config-tv": "0.1.4",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.23",
|
"@types/lodash": "4.17.23",
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface MediaTimeSegment {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
/** Represents a single downloaded media item with all necessary metadata for offline playback. */
|
||||||
export interface DownloadedItem {
|
export interface DownloadedItem {
|
||||||
/** The Jellyfin item DTO. */
|
/** The Jellyfin item DTO. */
|
||||||
@@ -50,12 +56,6 @@ export interface DownloadedItem {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** The credit segments for the item. */
|
/** The credit segments for the item. */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
/** The recap segments for the item. */
|
|
||||||
recapSegments?: MediaTimeSegment[];
|
|
||||||
/** The commercial segments for the item. */
|
|
||||||
commercialSegments?: MediaTimeSegment[];
|
|
||||||
/** The preview segments for the item. */
|
|
||||||
previewSegments?: MediaTimeSegment[];
|
|
||||||
/** The user data for the item. */
|
/** The user data for the item. */
|
||||||
userData: UserData;
|
userData: UserData;
|
||||||
}
|
}
|
||||||
@@ -144,12 +144,6 @@ export type JobStatus = {
|
|||||||
introSegments?: MediaTimeSegment[];
|
introSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
/** Pre-downloaded credit segments (optional) - downloaded before video starts */
|
||||||
creditSegments?: MediaTimeSegment[];
|
creditSegments?: MediaTimeSegment[];
|
||||||
/** Pre-downloaded recap segments (optional) - downloaded before video starts */
|
|
||||||
recapSegments?: MediaTimeSegment[];
|
|
||||||
/** Pre-downloaded commercial segments (optional) - downloaded before video starts */
|
|
||||||
commercialSegments?: MediaTimeSegment[];
|
|
||||||
/** Pre-downloaded preview segments (optional) - downloaded before video starts */
|
|
||||||
previewSegments?: MediaTimeSegment[];
|
|
||||||
/** The audio stream index selected for this download */
|
/** The audio stream index selected for this download */
|
||||||
audioStreamIndex?: number;
|
audioStreamIndex?: number;
|
||||||
/** The subtitle stream index selected for this download */
|
/** The subtitle stream index selected for this download */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -134,9 +134,6 @@ export enum VideoPlayer {
|
|||||||
MPV = 0,
|
MPV = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Segment skip behavior options
|
|
||||||
export type SegmentSkipMode = "none" | "ask" | "auto";
|
|
||||||
|
|
||||||
// Audio transcoding mode - controls how surround audio is handled
|
// Audio transcoding mode - controls how surround audio is handled
|
||||||
// This controls server-side transcoding behavior for audio streams.
|
// This controls server-side transcoding behavior for audio streams.
|
||||||
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
// MPV decodes via FFmpeg and supports most formats, but mobile devices
|
||||||
@@ -184,12 +181,6 @@ export type Settings = {
|
|||||||
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
maxAutoPlayEpisodeCount: MaxAutoPlayEpisodeCount;
|
||||||
autoPlayEpisodeCount: number;
|
autoPlayEpisodeCount: number;
|
||||||
autoPlayNextEpisode: boolean;
|
autoPlayNextEpisode: boolean;
|
||||||
// Media segment skip preferences
|
|
||||||
skipIntro: SegmentSkipMode;
|
|
||||||
skipOutro: SegmentSkipMode;
|
|
||||||
skipRecap: SegmentSkipMode;
|
|
||||||
skipCommercial: SegmentSkipMode;
|
|
||||||
skipPreview: SegmentSkipMode;
|
|
||||||
// Playback speed settings
|
// Playback speed settings
|
||||||
defaultPlaybackSpeed: number;
|
defaultPlaybackSpeed: number;
|
||||||
playbackSpeedPerMedia: Record<string, number>;
|
playbackSpeedPerMedia: Record<string, number>;
|
||||||
@@ -275,12 +266,6 @@ export const defaultValues: Settings = {
|
|||||||
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
maxAutoPlayEpisodeCount: { key: "3", value: 3 },
|
||||||
autoPlayEpisodeCount: 0,
|
autoPlayEpisodeCount: 0,
|
||||||
autoPlayNextEpisode: true,
|
autoPlayNextEpisode: true,
|
||||||
// Media segment skip defaults
|
|
||||||
skipIntro: "ask",
|
|
||||||
skipOutro: "ask",
|
|
||||||
skipRecap: "ask",
|
|
||||||
skipCommercial: "ask",
|
|
||||||
skipPreview: "ask",
|
|
||||||
// Playback speed defaults
|
// Playback speed defaults
|
||||||
defaultPlaybackSpeed: 1.0,
|
defaultPlaybackSpeed: 1.0,
|
||||||
playbackSpeedPerMedia: {},
|
playbackSpeedPerMedia: {},
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Helper Functions
|
|
||||||
* Common utilities for casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format milliseconds to HH:MM:SS or MM:SS
|
|
||||||
*/
|
|
||||||
export const formatTime = (ms: number): string => {
|
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate ending time based on current progress and duration.
|
|
||||||
* Uses locale-aware formatting when available.
|
|
||||||
*/
|
|
||||||
export const calculateEndingTime = (
|
|
||||||
currentMs: number,
|
|
||||||
durationMs: number,
|
|
||||||
): string => {
|
|
||||||
const remainingMs = durationMs - currentMs;
|
|
||||||
const endTime = new Date(Date.now() + remainingMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return endTime.toLocaleTimeString(undefined, {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Fallback for environments without Intl support
|
|
||||||
const hours = endTime.getHours();
|
|
||||||
const minutes = endTime.getMinutes();
|
|
||||||
const ampm = hours >= 12 ? "PM" : "AM";
|
|
||||||
const displayHours = hours % 12 || 12;
|
|
||||||
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get poster URL for item with specified dimensions
|
|
||||||
*/
|
|
||||||
export const getPosterUrl = (
|
|
||||||
baseUrl: string | undefined,
|
|
||||||
itemId: string | undefined,
|
|
||||||
tag: string | undefined,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
): string | null => {
|
|
||||||
if (!baseUrl || !itemId) return null;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
maxWidth: width.toString(),
|
|
||||||
maxHeight: height.toString(),
|
|
||||||
quality: "90",
|
|
||||||
...(tag && { tag }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return `${baseUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate title to max length with ellipsis
|
|
||||||
*/
|
|
||||||
export const truncateTitle = (title: string, maxLength: number): string => {
|
|
||||||
if (maxLength < 4) return title.substring(0, maxLength);
|
|
||||||
if (title.length <= maxLength) return title;
|
|
||||||
return `${title.substring(0, maxLength - 3)}...`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if current time is within a segment
|
|
||||||
*/
|
|
||||||
export const isWithinSegment = (
|
|
||||||
currentMs: number,
|
|
||||||
segment: { start: number; end: number } | null,
|
|
||||||
): boolean => {
|
|
||||||
if (!segment) return false;
|
|
||||||
const currentSeconds = currentMs / 1000;
|
|
||||||
return currentSeconds >= segment.start && currentSeconds <= segment.end;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format trickplay time from {hours, minutes, seconds} to display string.
|
|
||||||
* Produces "H:MM:SS" when hours > 0, otherwise "MM:SS".
|
|
||||||
*/
|
|
||||||
export const formatTrickplayTime = (time: {
|
|
||||||
hours: number;
|
|
||||||
minutes: number;
|
|
||||||
seconds: number;
|
|
||||||
}): string => {
|
|
||||||
const mm = String(time.minutes).padStart(2, "0");
|
|
||||||
const ss = String(time.seconds).padStart(2, "0");
|
|
||||||
return time.hours > 0 ? `${time.hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
|
||||||
};
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared helper to build Chromecast media metadata.
|
|
||||||
* Eliminates duplication between PlayButton, casting-player reloadWithSettings, and loadEpisode.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { MediaStreamType } from "react-native-google-cast";
|
|
||||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a MediaInfo object suitable for `remoteMediaClient.loadMedia()`.
|
|
||||||
*
|
|
||||||
* NOTE on contentType: Chromecast Default Media Receiver auto-detects HLS/DASH
|
|
||||||
* from the URL. Setting contentType to "application/x-mpegurl" or "application/dash+xml"
|
|
||||||
* actually BREAKS playback on many receivers. Always use "video/mp4" unless
|
|
||||||
* you have a custom receiver that explicitly handles other MIME types.
|
|
||||||
*/
|
|
||||||
export const buildCastMediaInfo = ({
|
|
||||||
item,
|
|
||||||
streamUrl,
|
|
||||||
api,
|
|
||||||
contentType,
|
|
||||||
isLive = false,
|
|
||||||
}: {
|
|
||||||
item: BaseItemDto;
|
|
||||||
streamUrl: string;
|
|
||||||
api: Api;
|
|
||||||
/** Override MIME type. Defaults to "video/mp4" which works for all stream types on Default Media Receiver. */
|
|
||||||
contentType?: string;
|
|
||||||
/** Set true for live TV streams to use MediaStreamType.LIVE. */
|
|
||||||
isLive?: boolean;
|
|
||||||
}) => {
|
|
||||||
if (!item.Id) {
|
|
||||||
throw new Error("Missing item.Id for media load — cannot build contentId");
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemId: string = item.Id;
|
|
||||||
const streamDuration = item.RunTimeTicks
|
|
||||||
? item.RunTimeTicks / 10000000
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const buildImages = (urls: (string | null | undefined)[]) =>
|
|
||||||
urls.filter(Boolean).map((url) => ({ url: url as string }));
|
|
||||||
|
|
||||||
const buildItemMetadata = () => {
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
return {
|
|
||||||
type: "tvShow" as const,
|
|
||||||
title: item.Name || "",
|
|
||||||
episodeNumber: item.IndexNumber || 0,
|
|
||||||
seasonNumber: item.ParentIndexNumber || 0,
|
|
||||||
seriesTitle: item.SeriesName || "",
|
|
||||||
images: buildImages([
|
|
||||||
getParentBackdropImageUrl({ api, item, quality: 90, width: 2000 }),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.Type === "Movie") {
|
|
||||||
return {
|
|
||||||
type: "movie" as const,
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: buildImages([
|
|
||||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "generic" as const,
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
images: buildImages([
|
|
||||||
getPrimaryImageUrl({ api, item, quality: 90, width: 2000 }),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const metadata = buildItemMetadata();
|
|
||||||
|
|
||||||
// Build a slim customData payload with only the fields the casting-player needs.
|
|
||||||
// Sending the full BaseItemDto can exceed the Cast protocol's ~64KB message limit,
|
|
||||||
// especially for movies with many chapters, media sources, and people.
|
|
||||||
const slimCustomData: Partial<BaseItemDto> = {
|
|
||||||
Id: item.Id,
|
|
||||||
Name: item.Name,
|
|
||||||
Type: item.Type,
|
|
||||||
SeriesName: item.SeriesName,
|
|
||||||
SeriesId: item.SeriesId,
|
|
||||||
SeasonId: item.SeasonId,
|
|
||||||
IndexNumber: item.IndexNumber,
|
|
||||||
ParentIndexNumber: item.ParentIndexNumber,
|
|
||||||
ImageTags: item.ImageTags,
|
|
||||||
RunTimeTicks: item.RunTimeTicks,
|
|
||||||
Overview: item.Overview,
|
|
||||||
MediaStreams: item.MediaStreams,
|
|
||||||
MediaSources: item.MediaSources?.map((src) => ({
|
|
||||||
Id: src.Id,
|
|
||||||
Bitrate: src.Bitrate,
|
|
||||||
Container: src.Container,
|
|
||||||
Name: src.Name,
|
|
||||||
})),
|
|
||||||
UserData: item.UserData
|
|
||||||
? { PlaybackPositionTicks: item.UserData.PlaybackPositionTicks }
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
contentId: itemId,
|
|
||||||
contentUrl: streamUrl,
|
|
||||||
contentType: contentType || "video/mp4",
|
|
||||||
streamType: isLive ? MediaStreamType.LIVE : MediaStreamType.BUFFERED,
|
|
||||||
streamDuration,
|
|
||||||
customData: slimCustomData,
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Types and Options
|
|
||||||
* Protocol-agnostic casting interface - currently supports Chromecast
|
|
||||||
* Architecture allows for future protocols (AirPlay, DLNA, etc.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
|
|
||||||
export type CastProtocol = "chromecast";
|
|
||||||
|
|
||||||
export interface CastDevice {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
protocol: CastProtocol;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CastPlayerState {
|
|
||||||
isConnected: boolean;
|
|
||||||
isPlaying: boolean;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
currentDevice: CastDevice | null;
|
|
||||||
protocol: CastProtocol | null;
|
|
||||||
progress: number;
|
|
||||||
duration: number;
|
|
||||||
volume: number;
|
|
||||||
isBuffering: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AudioTrack {
|
|
||||||
index: number;
|
|
||||||
language: string;
|
|
||||||
codec: string;
|
|
||||||
displayTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
|
||||||
index: number;
|
|
||||||
language: string;
|
|
||||||
codec: string;
|
|
||||||
displayTitle: string;
|
|
||||||
isForced: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaSource {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
bitrate?: number;
|
|
||||||
container: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CASTING_CONSTANTS = {
|
|
||||||
POSTER_WIDTH: 300,
|
|
||||||
POSTER_HEIGHT: 450,
|
|
||||||
ANIMATION_DURATION: 300,
|
|
||||||
CONTROL_HIDE_DELAY: 5000,
|
|
||||||
PROGRESS_UPDATE_INTERVAL: 1000,
|
|
||||||
SEEK_FORWARD_SECONDS: 10,
|
|
||||||
SEEK_BACKWARD_SECONDS: 10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const DEFAULT_CAST_STATE: CastPlayerState = {
|
|
||||||
isConnected: false,
|
|
||||||
isPlaying: false,
|
|
||||||
currentItem: null,
|
|
||||||
currentDevice: null,
|
|
||||||
protocol: null,
|
|
||||||
progress: 0,
|
|
||||||
duration: 0,
|
|
||||||
volume: 0.5,
|
|
||||||
isBuffering: false,
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast player configuration and types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ChromecastSegmentData {
|
|
||||||
intro: { start: number; end: number } | null;
|
|
||||||
credits: { start: number; end: number } | null;
|
|
||||||
recap: { start: number; end: number } | null;
|
|
||||||
commercial: { start: number; end: number }[];
|
|
||||||
preview: { start: number; end: number }[];
|
|
||||||
}
|
|
||||||
60
utils/chromecastLoadMedia.ts
Normal file
60
utils/chromecastLoadMedia.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { SelectedOptions } from "@/components/ItemContent";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { RemoteMediaClient, WebImage } from "react-native-google-cast";
|
||||||
|
import { ticksToSeconds } from "./time";
|
||||||
|
|
||||||
|
export function chromecastLoadMedia({
|
||||||
|
client,
|
||||||
|
item,
|
||||||
|
contentUrl,
|
||||||
|
sessionId,
|
||||||
|
mediaSourceId,
|
||||||
|
images,
|
||||||
|
playbackOptions,
|
||||||
|
}: {
|
||||||
|
client: RemoteMediaClient;
|
||||||
|
item: BaseItemDto;
|
||||||
|
contentUrl: string;
|
||||||
|
sessionId?: string;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
images: WebImage[];
|
||||||
|
playbackOptions: SelectedOptions;
|
||||||
|
}) {
|
||||||
|
return client.loadMedia({
|
||||||
|
mediaInfo: {
|
||||||
|
contentId: item.Id,
|
||||||
|
contentUrl,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
customData: {
|
||||||
|
item,
|
||||||
|
playbackOptions,
|
||||||
|
sessionId,
|
||||||
|
mediaSourceId,
|
||||||
|
},
|
||||||
|
metadata:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? {
|
||||||
|
type: "tvShow",
|
||||||
|
title: item.Name || "",
|
||||||
|
episodeNumber: item.IndexNumber || 0,
|
||||||
|
seasonNumber: item.ParentIndexNumber || 0,
|
||||||
|
seriesTitle: item.SeriesName || "",
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
: item.Type === "Movie"
|
||||||
|
? {
|
||||||
|
type: "movie",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "generic",
|
||||||
|
title: item.Name || "",
|
||||||
|
subtitle: item.Overview || "",
|
||||||
|
images,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startTime: ticksToSeconds(item.UserData?.PlaybackPositionTicks) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,14 +13,6 @@ export const chromecast: DeviceProfile = {
|
|||||||
{
|
{
|
||||||
Type: "Audio",
|
Type: "Audio",
|
||||||
Codec: "aac,mp3,flac,opus,vorbis",
|
Codec: "aac,mp3,flac,opus,vorbis",
|
||||||
// Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
|
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "AudioChannels",
|
|
||||||
Value: "2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ContainerProfiles: [],
|
ContainerProfiles: [],
|
||||||
@@ -92,5 +84,9 @@ export const chromecast: DeviceProfile = {
|
|||||||
Format: "vtt",
|
Format: "vtt",
|
||||||
Method: "Encode",
|
Method: "Encode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Format: "vtt",
|
||||||
|
Method: "Encode",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,14 +12,7 @@ export const chromecasth265: DeviceProfile = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Type: "Audio",
|
Type: "Audio",
|
||||||
Codec: "aac,mp3,flac,opus,vorbis", // Force transcode if audio has more than 2 channels (5.1, 7.1, etc)
|
Codec: "aac,mp3,flac,opus,vorbis",
|
||||||
Conditions: [
|
|
||||||
{
|
|
||||||
Condition: "LessThanEqual",
|
|
||||||
Property: "AudioChannels",
|
|
||||||
Value: "2",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
ContainerProfiles: [],
|
ContainerProfiles: [],
|
||||||
@@ -91,5 +84,9 @@ export const chromecasth265: DeviceProfile = {
|
|||||||
Format: "vtt",
|
Format: "vtt",
|
||||||
Method: "Encode",
|
Method: "Encode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Format: "vtt",
|
||||||
|
Method: "Encode",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,16 +74,10 @@ export const getSegmentsForItem = (
|
|||||||
): {
|
): {
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
} => {
|
} => {
|
||||||
return {
|
return {
|
||||||
introSegments: item.introSegments || [],
|
introSegments: item.introSegments || [],
|
||||||
creditSegments: item.creditSegments || [],
|
creditSegments: item.creditSegments || [],
|
||||||
recapSegments: item.recapSegments || [],
|
|
||||||
commercialSegments: item.commercialSegments || [],
|
|
||||||
previewSegments: item.previewSegments || [],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,9 +95,6 @@ const fetchMediaSegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
} | null> => {
|
} | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
const response = await api.axiosInstance.get<MediaSegmentsResponse>(
|
||||||
@@ -111,22 +102,13 @@ const fetchMediaSegments = async (
|
|||||||
{
|
{
|
||||||
headers: getAuthHeaders(api),
|
headers: getAuthHeaders(api),
|
||||||
params: {
|
params: {
|
||||||
includeSegmentTypes: [
|
includeSegmentTypes: ["Intro", "Outro"],
|
||||||
"Intro",
|
|
||||||
"Outro",
|
|
||||||
"Recap",
|
|
||||||
"Commercial",
|
|
||||||
"Preview",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const introSegments: MediaTimeSegment[] = [];
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
const creditSegments: MediaTimeSegment[] = [];
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
const recapSegments: MediaTimeSegment[] = [];
|
|
||||||
const commercialSegments: MediaTimeSegment[] = [];
|
|
||||||
const previewSegments: MediaTimeSegment[] = [];
|
|
||||||
|
|
||||||
response.data.Items.forEach((segment) => {
|
response.data.Items.forEach((segment) => {
|
||||||
const timeSegment: MediaTimeSegment = {
|
const timeSegment: MediaTimeSegment = {
|
||||||
@@ -142,27 +124,13 @@ const fetchMediaSegments = async (
|
|||||||
case "Outro":
|
case "Outro":
|
||||||
creditSegments.push(timeSegment);
|
creditSegments.push(timeSegment);
|
||||||
break;
|
break;
|
||||||
case "Recap":
|
// Optionally handle other types like Recap, Commercial, Preview
|
||||||
recapSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case "Commercial":
|
|
||||||
commercialSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
case "Preview":
|
|
||||||
previewSegments.push(timeSegment);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { introSegments, creditSegments };
|
||||||
introSegments,
|
|
||||||
creditSegments,
|
|
||||||
recapSegments,
|
|
||||||
commercialSegments,
|
|
||||||
previewSegments,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Return null to indicate we should try legacy endpoints
|
// Return null to indicate we should try legacy endpoints
|
||||||
return null;
|
return null;
|
||||||
@@ -178,47 +146,45 @@ const fetchLegacySegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
}> => {
|
}> => {
|
||||||
const introSegments: MediaTimeSegment[] = [];
|
const introSegments: MediaTimeSegment[] = [];
|
||||||
const creditSegments: MediaTimeSegment[] = [];
|
const creditSegments: MediaTimeSegment[] = [];
|
||||||
|
|
||||||
const [introRes, creditRes] = await Promise.allSettled([
|
try {
|
||||||
api.axiosInstance.get<IntroTimestamps>(
|
const [introRes, creditRes] = await Promise.allSettled([
|
||||||
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
api.axiosInstance.get<IntroTimestamps>(
|
||||||
{ headers: getAuthHeaders(api) },
|
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
|
||||||
),
|
{ headers: getAuthHeaders(api) },
|
||||||
api.axiosInstance.get<CreditTimestamps>(
|
),
|
||||||
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
api.axiosInstance.get<CreditTimestamps>(
|
||||||
{ headers: getAuthHeaders(api) },
|
`${api.basePath}/Episode/${itemId}/Timestamps`,
|
||||||
),
|
{ headers: getAuthHeaders(api) },
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
if (introRes.status === "fulfilled" && introRes.value.data.Valid) {
|
||||||
introSegments.push({
|
introSegments.push({
|
||||||
startTime: introRes.value.data.IntroStart,
|
startTime: introRes.value.data.IntroStart,
|
||||||
endTime: introRes.value.data.IntroEnd,
|
endTime: introRes.value.data.IntroEnd,
|
||||||
text: "Intro",
|
text: "Intro",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
creditRes.status === "fulfilled" &&
|
||||||
|
creditRes.value.data.Credits.Valid
|
||||||
|
) {
|
||||||
|
creditSegments.push({
|
||||||
|
startTime: creditRes.value.data.Credits.Start,
|
||||||
|
endTime: creditRes.value.data.Credits.End,
|
||||||
|
text: "Credits",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch legacy segments", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creditRes.status === "fulfilled" && creditRes.value.data.Credits.Valid) {
|
return { introSegments, creditSegments };
|
||||||
creditSegments.push({
|
|
||||||
startTime: creditRes.value.data.Credits.Start,
|
|
||||||
endTime: creditRes.value.data.Credits.End,
|
|
||||||
text: "Credits",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
introSegments,
|
|
||||||
creditSegments,
|
|
||||||
recapSegments: [],
|
|
||||||
commercialSegments: [],
|
|
||||||
previewSegments: [],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchAndParseSegments = async (
|
export const fetchAndParseSegments = async (
|
||||||
@@ -227,9 +193,6 @@ export const fetchAndParseSegments = async (
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
introSegments: MediaTimeSegment[];
|
introSegments: MediaTimeSegment[];
|
||||||
creditSegments: MediaTimeSegment[];
|
creditSegments: MediaTimeSegment[];
|
||||||
recapSegments: MediaTimeSegment[];
|
|
||||||
commercialSegments: MediaTimeSegment[];
|
|
||||||
previewSegments: MediaTimeSegment[];
|
|
||||||
}> => {
|
}> => {
|
||||||
// Try new API first (Jellyfin 10.11+)
|
// Try new API first (Jellyfin 10.11+)
|
||||||
const newSegments = await fetchMediaSegments(itemId, api);
|
const newSegments = await fetchMediaSegments(itemId, api);
|
||||||
|
|||||||
Reference in New Issue
Block a user