mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
Compare commits
59 Commits
test/tvos-
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c400a54a | ||
|
|
ea5a999f21 | ||
|
|
dffcdef945 | ||
|
|
fa1c3f3947 | ||
|
|
2761de5a74 | ||
|
|
261f7cc0cd | ||
|
|
d06daef933 | ||
|
|
feca1d7e9c | ||
|
|
6b6bfd1a89 | ||
|
|
d585b20f49 | ||
|
|
692ccfdb2c | ||
|
|
86e39c444c | ||
|
|
ed7928b4d3 | ||
|
|
27dc7b5664 | ||
|
|
a205c75895 | ||
|
|
a4d4f588fb | ||
|
|
aedb7bc51d | ||
|
|
5a3e9c51c9 | ||
|
|
252c58f120 | ||
|
|
d2e73021b1 | ||
|
|
6876ce046f | ||
|
|
e044859aaf | ||
|
|
c93132177c | ||
|
|
2166bb3867 | ||
|
|
f9b71ef648 | ||
|
|
d11fb3d0c0 | ||
|
|
dd3ca37108 | ||
|
|
b588195456 | ||
|
|
fe4d90df26 | ||
|
|
a190122eea | ||
|
|
0bf8fac079 | ||
|
|
37b51abd34 | ||
|
|
6fe464088b | ||
|
|
769c7a2432 | ||
|
|
62c86533b1 | ||
|
|
4fc78f006d | ||
|
|
ab0957044f | ||
|
|
407ef3f51e | ||
|
|
0e531da2e0 | ||
|
|
1cabbf087e | ||
|
|
0f86c776ba | ||
|
|
07b79de203 | ||
|
|
e4b0161d15 | ||
|
|
6e223596f6 | ||
|
|
04e75c81a4 | ||
|
|
b6ea6d4f14 | ||
|
|
2c58636843 | ||
|
|
d9266209d2 | ||
|
|
4cc11403f8 | ||
|
|
0ba3f44615 | ||
|
|
a3ed822bf4 | ||
|
|
f8414194f0 | ||
|
|
1e9c9fb67f | ||
|
|
3c7292b73b | ||
|
|
708d0e8d2e | ||
|
|
74f5844ed7 | ||
|
|
38d638cdeb | ||
|
|
afe9d33ee4 | ||
|
|
5db4a79e8a |
@@ -45,4 +45,4 @@ This file previously contained facts about the codebase learned from past sessio
|
||||
|
||||
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||
|
||||
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
||||
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_
|
||||
|
||||
95
.github/pull_request_template.md
vendored
95
.github/pull_request_template.md
vendored
@@ -1,91 +1,54 @@
|
||||
<!--
|
||||
Pull Request Template for Streamyfin
|
||||
====================================
|
||||
Use this template to help reviewers understand the purpose of your PR
|
||||
and to ensure all necessary checks are completed before merging.
|
||||
<!--
|
||||
Use a conventional commit title for the PR title,
|
||||
for example `feat(auth): add MFA`
|
||||
All sections below are required. Write N/A if a section is not applicable.
|
||||
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
|
||||
-->
|
||||
|
||||
# 📦 Pull Request
|
||||
|
||||
## 🔖 Summary
|
||||
<!--
|
||||
🤖 AI ASSISTED?
|
||||
Uncomment the line below if AI was used to assist with this PR:
|
||||
-->
|
||||
<!--
|
||||
[](#) -->
|
||||
|
||||
## 📝 Description
|
||||
<!--
|
||||
A concise description of the changes introduced by this PR.
|
||||
Example:
|
||||
“Add real-time currency conversion widget to dashboard.”
|
||||
A short description of the changes and why you're making them.
|
||||
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
||||
-->
|
||||
|
||||
## 🏷️ Ticket / Issue
|
||||
<!--
|
||||
Link to the related ticket, issue or user story.
|
||||
You can also indicate if this PR supersedes a previous one.
|
||||
Example:
|
||||
- Closes #123
|
||||
- Fixes STREAMYFIN-456
|
||||
- Resolves #789
|
||||
- Supersedes #120
|
||||
- Related: #130
|
||||
Example: Fixes #123
|
||||
-->
|
||||
|
||||
## 🛠️ What’s Changed
|
||||
<!-- Use a Conventional Commit in the PR title, e.g., `feat(auth): add MFA`.
|
||||
If this PR introduces a breaking change, include a `BREAKING CHANGE:` block in the description.
|
||||
Spec: https://www.conventionalcommits.org/ -->
|
||||
|
||||
- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
|
||||
- Scope (optional): e.g., auth, billing, mobile
|
||||
- Short summary: what changed and why (1–2 lines)
|
||||
-->
|
||||
|
||||
## 📋 Details
|
||||
<!--
|
||||
Provide more context or background. Explain any non-obvious decisions.
|
||||
Include screenshots or GIFs for UI changes if applicable.
|
||||
-->
|
||||
|
||||
### ⚠️ Breaking Changes
|
||||
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
|
||||
|
||||
### 🔐 Security & Privacy Impact
|
||||
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
|
||||
|
||||
### ⚡ Performance Impact
|
||||
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
|
||||
|
||||
### 🖼️ Screenshots / GIFs (if UI)
|
||||
<!-- Before/After, dark mode, responsive states. -->
|
||||
<!--
|
||||
Include screenshots of relevant UI changes for both Android and iOS.
|
||||
Before/After, responsive states (if relevant).
|
||||
-->
|
||||
|
||||
## ✅ Checklist
|
||||
<!--
|
||||
Review and check off items as you complete them.
|
||||
-->
|
||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
||||
- [ ] Type checks pass (tsc/biome/etc.)
|
||||
- [ ] Docs updated (README/ADR/usage/API)
|
||||
- [ ] No secrets/credentials included; env vars documented
|
||||
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
||||
- [ ] Verified locally that changes behave as expected
|
||||
- [ ] Verified that changes behave as expected for all platforms
|
||||
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||
- [ ] No secrets, hardcoded credentials, or private config files are included
|
||||
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||
|
||||
## 🔍 Testing Instructions
|
||||
<!--
|
||||
Describe how reviewers can test your changes.
|
||||
Describe how reviewers can test your changes. This will help the PR get merged faster.
|
||||
Example:
|
||||
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
||||
2. Install deps: `npm|pnpm|yarn|bun install`
|
||||
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
||||
4. Run tests: `npm|pnpm|yarn|bun test`
|
||||
5. Verification steps:
|
||||
- [ ] Expected UI/endpoint behavior
|
||||
- [ ] Logs show no errors
|
||||
- [ ] Edge cases covered (list)
|
||||
1. Open the settings page and scroll to the bottom
|
||||
2. Verify that the clear data button is visible and pressable
|
||||
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
|
||||
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
|
||||
-->
|
||||
|
||||
## ⚙️ Deployment Notes
|
||||
<!--
|
||||
Describe any deployment considerations such as config, environment vars, or native builds.
|
||||
-->
|
||||
|
||||
## 📝 Additional Notes
|
||||
<!--
|
||||
Any other information or references related to this PR.
|
||||
-->
|
||||
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@@ -25,6 +25,25 @@
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"configMigration": true,
|
||||
"separateMinorPatch": true,
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
||||
"matchStrings": [
|
||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
||||
],
|
||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
||||
}
|
||||
],
|
||||
"customDatasources": {
|
||||
"xcode": {
|
||||
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
|
||||
"format": "json",
|
||||
"transformTemplates": [
|
||||
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lockFileMaintenance": {
|
||||
"vulnerabilityAlerts": {
|
||||
"enabled": true,
|
||||
|
||||
19
.github/workflows/build-apps.yml
vendored
19
.github/workflows/build-apps.yml
vendored
@@ -218,7 +218,8 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
@@ -282,7 +283,8 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
@@ -341,7 +343,8 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🏗️ Setup EAS
|
||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||
@@ -366,9 +369,10 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
build-ios-tv-unsigned:
|
||||
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
|
||||
# Re-enable by removing the `false &&` prefix below.
|
||||
if: false && (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
# Unsigned tvOS build is enabled (compiles without Apple credentials).
|
||||
# The signed tvOS job above stays disabled until tvOS provisioning
|
||||
# profiles are set up in EAS (app + TopShelf targets).
|
||||
if: (!contains(github.event.head_commit.message, '[skip ci]'))
|
||||
runs-on: macos-26
|
||||
name: 🍎 Build tvOS IPA (Unsigned)
|
||||
permissions:
|
||||
@@ -407,7 +411,8 @@ jobs:
|
||||
- name: 🔧 Setup Xcode
|
||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||
with:
|
||||
xcode-version: "26.2"
|
||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
||||
xcode-version: "26.4"
|
||||
|
||||
- name: 🚀 Build iOS app
|
||||
env:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -74,3 +74,5 @@ modules/background-downloader/android/build/*
|
||||
# ios:unsigned-build Artifacts
|
||||
build/
|
||||
.claude/
|
||||
.agents/skills/**
|
||||
skills-lock.json
|
||||
|
||||
14
app.json
14
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.54.0",
|
||||
"version": "0.54.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -36,7 +36,6 @@
|
||||
"appleTeamId": "MWD5K362T8"
|
||||
},
|
||||
"android": {
|
||||
"versionCode": 93,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||
@@ -78,15 +77,16 @@
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": {
|
||||
"deploymentTarget": "15.6",
|
||||
"useFrameworks": "static"
|
||||
"deploymentTarget": "16.4",
|
||||
"useFrameworks": "static",
|
||||
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
|
||||
},
|
||||
"android": {
|
||||
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 35,
|
||||
"buildToolsVersion": "35.0.0",
|
||||
"kotlinVersion": "2.0.21",
|
||||
"kotlinVersion": "2.1.20",
|
||||
"minSdkVersion": 26,
|
||||
"usesCleartextTraffic": true,
|
||||
"packagingOptions": {
|
||||
@@ -143,8 +143,8 @@
|
||||
[
|
||||
"./plugins/withGitPod.js",
|
||||
{
|
||||
"podName": "MPVKit-GPL",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
|
||||
"podName": "MPVKit",
|
||||
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface MenuLink {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export default function menuLinks() {
|
||||
export default function CustomLinksPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
|
||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
|
||||
export default function favorites() {
|
||||
export default function FavoritesPage() {
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -7,8 +7,12 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageSta
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
const SyncPlayButtonComponent = Platform.isTV
|
||||
? null
|
||||
: require("@/components/syncplay/SyncPlayButton").SyncPlayButton;
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -32,6 +36,7 @@ export default function IndexLayout() {
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast background='transparent' />
|
||||
{SyncPlayButtonComponent && <SyncPlayButtonComponent />}
|
||||
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
@@ -47,15 +52,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
title: t("home.downloads.downloads_title"),
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -66,15 +63,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -104,15 +93,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -123,15 +104,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -142,15 +115,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -161,15 +126,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -180,15 +137,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -199,15 +148,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -237,15 +178,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -256,15 +189,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -275,15 +200,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -294,15 +211,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -313,15 +222,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -332,15 +233,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -351,15 +244,7 @@ export default function IndexLayout() {
|
||||
headerBlurEffect: "none",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerLeft: () => (
|
||||
<Pressable
|
||||
onPress={() => _router.back()}
|
||||
className='pl-0.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
}}
|
||||
/>
|
||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||
@@ -369,11 +254,7 @@ export default function IndexLayout() {
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerLeft: () => (
|
||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
||||
<Feather name='chevron-left' size={28} color='white' />
|
||||
</Pressable>
|
||||
),
|
||||
headerLeft: () => <HeaderBackButton />,
|
||||
headerShown: !Platform.isTV,
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
|
||||
@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
export default function DownloadsPage() {
|
||||
const navigation = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
|
||||
export default function page() {
|
||||
export default function SessionsPage() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||
if (!session.NowPlayingItem?.RunTimeTicks) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,17 +59,17 @@ function SettingsMobile() {
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<TouchableOpacity
|
||||
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
|
||||
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
|
||||
>
|
||||
<Text className='text-white font-bold text-base mb-1'>
|
||||
{t("pairing.pair_with_phone_title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{t("pairing.pair_with_phone_description")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
|
||||
@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function AppearanceHideLibrariesPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function HideLibrariesPage() {
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrPluginPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function KefinTweaksPage() {
|
||||
const { pluginSettings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function MarlinSearchPage() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export default function page() {
|
||||
export default function StreamystatsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -37,8 +37,10 @@ const Page: React.FC = () => {
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// Lazily preload item with full media sources in background
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
||||
// Lazily preload item with full media sources in background — never cache
|
||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrCompanyPage() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrGenrePage() {
|
||||
const local = useLocalSearchParams();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
export default function page() {
|
||||
export default function JellyseerrPersonPage() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
} from "expo-router/js-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||
} from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export default function page() {
|
||||
export default function LiveTvChannelsPage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const _insets = useSafeAreaInsets();
|
||||
|
||||
@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
|
||||
|
||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||
|
||||
export default function page() {
|
||||
export default function LiveTvGuidePage() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
export default function page() {
|
||||
export default function LiveTvRecordingsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className='flex items-center justify-center h-full -mt-12'>
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
@@ -104,6 +105,10 @@ const Page = () => {
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
|
||||
// Fallback refresh for newly added content when returning to the library
|
||||
// (primary path is the LibraryChanged WebSocket event).
|
||||
useRefreshLibraryOnFocus();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||
import {
|
||||
createMaterialTopTabNavigator,
|
||||
MaterialTopTabNavigationEventMap,
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
} from "expo-router/js-top-tabs";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||
} from "expo-router/react-navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Navigator } = createMaterialTopTabNavigator();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useNavigation, useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useRoute } from "expo-router/react-navigation";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -66,7 +66,7 @@ const exampleSearches = [
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
export default function search() {
|
||||
export default function SearchPage() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@@ -221,7 +221,7 @@ export default function search() {
|
||||
|
||||
const ids = response1.data.ids;
|
||||
|
||||
if (!ids || !ids.length) {
|
||||
if (!ids?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import {
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
type NativeBottomTabNavigationOptions,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { withLayoutContext } from "expo-router";
|
||||
} from "expo-router/react-navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
@@ -49,7 +49,8 @@ import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useInactivity } from "@/providers/InactivityProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
||||
|
||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||
import type { PlayerControls } from "@/providers/SyncPlay/types";
|
||||
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
@@ -63,7 +64,7 @@ import { writeToLog } from "@/utils/log";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||
|
||||
export default function page() {
|
||||
export default function DirectPlayerPage() {
|
||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||
const user = useAtomValue(userAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -128,6 +129,7 @@ export default function page() {
|
||||
bitrateValue: bitrateValueStr,
|
||||
offline: offlineStr,
|
||||
playbackPosition: playbackPositionFromUrl,
|
||||
syncPlay: syncPlayStr,
|
||||
} = useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -137,9 +139,24 @@ export default function page() {
|
||||
offline: string;
|
||||
/** Playback position in ticks. */
|
||||
playbackPosition?: string;
|
||||
/** Whether playback was initiated by SyncPlay */
|
||||
syncPlay?: string;
|
||||
}>();
|
||||
|
||||
// When opened via SyncPlay, don't auto-play - let SyncPlay commands control playback
|
||||
const openedViaSyncPlay = syncPlayStr === "true";
|
||||
const { lockOrientation, unlockOrientation } = useOrientation();
|
||||
|
||||
// SyncPlay integration
|
||||
const syncPlay = useSyncPlay();
|
||||
const {
|
||||
isEnabled: isSyncPlayEnabled,
|
||||
controller: syncPlayController,
|
||||
setPlayerControls,
|
||||
notifyReady,
|
||||
notifyBuffering,
|
||||
} = syncPlay;
|
||||
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
// Audio index: use URL param if provided, otherwise use stored index for offline playback
|
||||
@@ -185,11 +202,11 @@ export default function page() {
|
||||
return undefined;
|
||||
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
|
||||
|
||||
// Initialize TV audio/subtitle indices from URL params
|
||||
// Initialize TV audio/subtitle indices from URL params.
|
||||
// No undefined guard: when a new episode's URL omits audioIndex, reset to
|
||||
// undefined (media default) rather than leaking the previous episode's track.
|
||||
useEffect(() => {
|
||||
if (audioIndex !== undefined) {
|
||||
setCurrentAudioIndex(audioIndex);
|
||||
}
|
||||
setCurrentAudioIndex(audioIndex);
|
||||
}, [audioIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -317,7 +334,7 @@ export default function page() {
|
||||
}
|
||||
|
||||
let result: Stream | null = null;
|
||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
||||
if (offline && downloadedItem?.mediaSource) {
|
||||
const url = downloadedItem.videoFilePath;
|
||||
if (item) {
|
||||
result = {
|
||||
@@ -403,8 +420,102 @@ export default function page() {
|
||||
reportPlaybackStart();
|
||||
}, [stream, api, offline]);
|
||||
|
||||
// SyncPlay: Connect player controls when video is ready
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || !videoRef.current || offline) {
|
||||
setPlayerControls(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controls: PlayerControls = {
|
||||
play: () => videoRef.current?.play(),
|
||||
pause: () => videoRef.current?.pause(),
|
||||
seekTo: (positionMs: number) => {
|
||||
const positionSec = positionMs / 1000;
|
||||
console.log(
|
||||
`PlayerControls.seekTo: ${positionMs}ms = ${positionSec}s, videoRef exists: ${!!videoRef.current}`,
|
||||
);
|
||||
videoRef.current?.seekTo(positionSec);
|
||||
},
|
||||
setSpeed: (speed: number) => videoRef.current?.setSpeed?.(speed),
|
||||
getSpeed: () => currentPlaybackSpeed,
|
||||
getCurrentPosition: () => progress.get(),
|
||||
isPlaying: () => isPlaying,
|
||||
isBuffering: () => isBuffering,
|
||||
};
|
||||
|
||||
setPlayerControls(controls);
|
||||
|
||||
return () => {
|
||||
setPlayerControls(null);
|
||||
};
|
||||
}, [
|
||||
isVideoLoaded,
|
||||
offline,
|
||||
isPlaying,
|
||||
isBuffering,
|
||||
currentPlaybackSpeed,
|
||||
progress,
|
||||
setPlayerControls,
|
||||
]);
|
||||
|
||||
// SyncPlay: Report buffering/ready state to server.
|
||||
//
|
||||
// CRITICAL: We must report `buffering` to the server *during* initial
|
||||
// load (before `isVideoLoaded`), otherwise the server treats us as ready
|
||||
// and proceeds without waiting for us. jellyfin-web reports this for
|
||||
// free via the HTML5 video element's `waiting` event; for us, the
|
||||
// initial load itself is the buffering window.
|
||||
useEffect(() => {
|
||||
if (!isSyncPlayEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isLocallyReady = isVideoLoaded && !isBuffering;
|
||||
if (isLocallyReady) {
|
||||
notifyReady();
|
||||
} else {
|
||||
notifyBuffering();
|
||||
}
|
||||
}, [
|
||||
isSyncPlayEnabled,
|
||||
isVideoLoaded,
|
||||
isBuffering,
|
||||
notifyReady,
|
||||
notifyBuffering,
|
||||
]);
|
||||
|
||||
// SyncPlay: Pause playback when group is waiting
|
||||
useEffect(() => {
|
||||
if (!isSyncPlayEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupState = syncPlay.groupInfo?.State;
|
||||
const isLocalReady = isVideoLoaded && !isBuffering;
|
||||
const isWaitingForGroup = groupState === "Waiting";
|
||||
|
||||
// Pause playback when waiting for group
|
||||
if (isLocalReady && isWaitingForGroup && isPlaying) {
|
||||
videoRef.current?.pause();
|
||||
}
|
||||
}, [
|
||||
isSyncPlayEnabled,
|
||||
syncPlay.groupInfo?.State,
|
||||
isVideoLoaded,
|
||||
isBuffering,
|
||||
isPlaying,
|
||||
]);
|
||||
|
||||
const togglePlay = async () => {
|
||||
lightHapticFeedback();
|
||||
|
||||
// Route through SyncPlay when active
|
||||
if (isSyncPlayEnabled && syncPlayController) {
|
||||
syncPlayController.playPause();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaying(!isPlaying);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
@@ -470,8 +581,11 @@ export default function page() {
|
||||
|
||||
return {
|
||||
ItemId: item.Id,
|
||||
AudioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
// Report the live selection so server-side session/resume state reflects
|
||||
// mid-playback track changes. Note: index 0 is valid (don't treat as
|
||||
// falsy); -1 means "off" and is reported as-is.
|
||||
AudioStreamIndex: currentAudioIndex,
|
||||
SubtitleStreamIndex: currentSubtitleIndex,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PositionTicks: msToTicks(progress.get()),
|
||||
IsPaused: !isPlaying,
|
||||
@@ -485,8 +599,8 @@ export default function page() {
|
||||
}, [
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
currentAudioIndex,
|
||||
currentSubtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
isPlaying,
|
||||
@@ -553,8 +667,8 @@ export default function page() {
|
||||
},
|
||||
[
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
currentAudioIndex,
|
||||
currentSubtitleIndex,
|
||||
mediaSourceId,
|
||||
isPlaying,
|
||||
stream,
|
||||
@@ -635,10 +749,12 @@ export default function page() {
|
||||
const startPos = ticksToSeconds(startTicks);
|
||||
|
||||
// Build source config - headers only needed for online streaming
|
||||
// When opened via SyncPlay, don't auto-play - SyncPlay commands control playback
|
||||
const shouldAutoplay = !openedViaSyncPlay;
|
||||
const source: MpvVideoSource = {
|
||||
url: stream.url,
|
||||
startPosition: startPos,
|
||||
autoplay: true,
|
||||
autoplay: shouldAutoplay,
|
||||
initialSubtitleId,
|
||||
initialAudioId,
|
||||
// Pass cache/buffer settings from user preferences
|
||||
@@ -822,12 +938,10 @@ export default function page() {
|
||||
],
|
||||
);
|
||||
|
||||
/** PiP handler for MPV */
|
||||
const _onPictureInPictureChange = useCallback(
|
||||
(e: { nativeEvent: { isActive: boolean } }) => {
|
||||
const { isActive } = e.nativeEvent;
|
||||
setIsPipMode(isActive);
|
||||
// Hide controls when entering PiP
|
||||
if (isActive) {
|
||||
_setShowControls(false);
|
||||
}
|
||||
@@ -845,6 +959,9 @@ export default function page() {
|
||||
|
||||
// Memoize video ref functions to prevent unnecessary re-renders
|
||||
const startPictureInPicture = useCallback(async () => {
|
||||
// Hide controls BEFORE entering PiP so the window captures a clean view
|
||||
_setShowControls(false);
|
||||
setIsPipMode(true);
|
||||
return videoRef.current?.startPictureInPicture?.();
|
||||
}, []);
|
||||
|
||||
@@ -856,10 +973,20 @@ export default function page() {
|
||||
videoRef.current?.pause?.();
|
||||
}, []);
|
||||
|
||||
const seek = useCallback((position: number) => {
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
}, []);
|
||||
const seek = useCallback(
|
||||
(position: number) => {
|
||||
// Route through SyncPlay when active
|
||||
if (isSyncPlayEnabled && syncPlayController) {
|
||||
console.log("SyncPlay: seek requested via SyncPlay", position);
|
||||
syncPlayController.seekMs(position);
|
||||
return;
|
||||
}
|
||||
|
||||
// MPV expects seconds, convert from ms
|
||||
videoRef.current?.seekTo?.(position / 1000);
|
||||
},
|
||||
[isSyncPlayEnabled, syncPlayController],
|
||||
);
|
||||
|
||||
// TV audio track change handler
|
||||
const handleAudioIndexChange = useCallback(
|
||||
@@ -1009,8 +1136,9 @@ export default function page() {
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(previousItem, settings, {
|
||||
indexes: {
|
||||
subtitleIndex: subtitleIndex,
|
||||
audioIndex: audioIndex,
|
||||
// Use the live selection, not the stale URL params (see goToNextItem).
|
||||
subtitleIndex: currentSubtitleIndex,
|
||||
audioIndex: currentAudioIndex,
|
||||
},
|
||||
source: stream?.mediaSource ?? undefined,
|
||||
});
|
||||
@@ -1029,8 +1157,8 @@ export default function page() {
|
||||
}, [
|
||||
previousItem,
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
currentSubtitleIndex,
|
||||
currentAudioIndex,
|
||||
stream?.mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
@@ -1075,8 +1203,10 @@ export default function page() {
|
||||
subtitleIndex: defaultSubtitleIndex,
|
||||
} = getDefaultPlaySettings(nextItem, settings, {
|
||||
indexes: {
|
||||
subtitleIndex: subtitleIndex,
|
||||
audioIndex: audioIndex,
|
||||
// Use the live selection (updated when the user changes tracks
|
||||
// mid-playback), not the stale URL params the episode started with.
|
||||
subtitleIndex: currentSubtitleIndex,
|
||||
audioIndex: currentAudioIndex,
|
||||
},
|
||||
source: stream?.mediaSource ?? undefined,
|
||||
});
|
||||
@@ -1095,8 +1225,8 @@ export default function page() {
|
||||
}, [
|
||||
nextItem,
|
||||
settings,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
currentSubtitleIndex,
|
||||
currentAudioIndex,
|
||||
stream?.mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
@@ -1247,6 +1377,7 @@ export default function page() {
|
||||
nowPlayingMetadata={nowPlayingMetadata}
|
||||
onProgress={onProgress}
|
||||
onPlaybackStateChange={onPlaybackStateChanged}
|
||||
onPictureInPictureChange={_onPictureInPictureChange}
|
||||
onLoad={() => setIsVideoLoaded(true)}
|
||||
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
|
||||
@@ -1248,7 +1248,7 @@ const styles = StyleSheet.create({
|
||||
color: "#fff",
|
||||
},
|
||||
downloadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: scaleSize(14),
|
||||
justifyContent: "center",
|
||||
|
||||
261
app/_layout.tsx
261
app/_layout.tsx
@@ -2,12 +2,12 @@ import "@/augmentations";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import NetInfo from "@react-native-community/netinfo";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
import * as BackgroundTask from "expo-background-task";
|
||||
import * as Device from "expo-device";
|
||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
||||
import { Platform } from "react-native";
|
||||
import { GlobalModal } from "@/components/GlobalModal";
|
||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||
@@ -25,6 +25,7 @@ import { MusicPlayerProvider } from "@/providers/MusicPlayerProvider";
|
||||
import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
|
||||
import { SyncPlayProvider } from "@/providers/SyncPlay";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
@@ -395,8 +396,9 @@ function Layout() {
|
||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||
dehydrateOptions: {
|
||||
shouldDehydrateQuery: (query) => {
|
||||
// Only persist successful queries
|
||||
return query.state.status === "success";
|
||||
return (
|
||||
query.state.status === "success" && query.options.gcTime !== 0
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -408,133 +410,136 @@ function Layout() {
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<SyncPlayProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent:
|
||||
Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-option-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-subtitle-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-request-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-season-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-series-season-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-account-action-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-account-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-user-switch-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/now-playing'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "modal",
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name='+not-found' />
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-option-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-subtitle-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-request-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-season-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-series-season-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-account-action-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='tv-account-select-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='(auth)/tv-user-switch-modal'
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "transparentModal",
|
||||
animation: "fade",
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
toastOptions={{
|
||||
style: {
|
||||
backgroundColor: "#262626",
|
||||
borderColor: "#363639",
|
||||
borderWidth: 1,
|
||||
},
|
||||
titleStyle: {
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
closeButton
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</SyncPlayProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/*",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
diff --git a/lib/generators/modules/GenerateModuleObjCpp/index.js b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||
index 927711514d2deaa3c795fb98e676e0a1f596eddc..0364d66204a76fccd3e06a0dc72bf801aa04a50d 100644
|
||||
--- a/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||
+++ b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||
@@ -67,9 +67,12 @@ const HeaderFileTemplate = ({
|
||||
* must have a single output. More files => more genrule()s => slower builds.
|
||||
*/
|
||||
|
||||
-#ifndef __cplusplus
|
||||
-#error This file must be compiled as Obj-C++. If you are importing it, you must change your file extension to .mm.
|
||||
-#endif
|
||||
+// Patched: guard the Obj-C++ body with __cplusplus instead of hard-#error-ing.
|
||||
+// With use_frameworks! :static + New Arch, plain Obj-C .m TUs can trigger a
|
||||
+// Clang module build (via Swift-interop -Swift.h umbrellas) that pulls this
|
||||
+// header in Obj-C mode. Skipping the body (instead of erroring) lets the module
|
||||
+// build; .mm consumers still get the full Obj-C++ contents unchanged.
|
||||
+#if defined(__cplusplus)
|
||||
|
||||
// Avoid multiple includes of ${headerFileNameWithNoExt} symbols
|
||||
#ifndef ${headerFileNameWithNoExt}_H
|
||||
@@ -93,7 +96,7 @@ const HeaderFileTemplate = ({
|
||||
structInlineMethods +
|
||||
(assumeNonnull ? '\nNS_ASSUME_NONNULL_END\n' : '\n') +
|
||||
`#endif // ${headerFileNameWithNoExt}_H` +
|
||||
- '\n'
|
||||
+ '\n#endif // defined(__cplusplus)\n'
|
||||
);
|
||||
};
|
||||
const SourceFileTemplate = ({headerFileName, moduleImplementations}) => `/**
|
||||
@@ -1,191 +0,0 @@
|
||||
diff --git a/node_modules/react-native-screens/.bun-tag-10a3b0add1bd4de6 b/.bun-tag-10a3b0add1bd4de6
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/node_modules/react-native-screens/.bun-tag-6a8504b742d5cfff b/.bun-tag-6a8504b742d5cfff
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/node_modules/react-native-screens/.bun-tag-d28396854bc27a3d b/.bun-tag-d28396854bc27a3d
|
||||
new file mode 100644
|
||||
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
|
||||
index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
|
||||
--- a/ios/RNSScreenStack.mm
|
||||
+++ b/ios/RNSScreenStack.mm
|
||||
@@ -34,6 +34,11 @@
|
||||
#import "integrations/RNSDismissibleModalProtocol.h"
|
||||
#import "utils/UINavigationBar+RNSUtility.h"
|
||||
|
||||
+#if TARGET_OS_TV
|
||||
+#import <React/RCTTVNavigationEventNotification.h>
|
||||
+#import <React/RCTTVRemoteHandler.h>
|
||||
+#endif // TARGET_OS_TV
|
||||
+
|
||||
#ifdef RNS_GAMMA_ENABLED
|
||||
#import "RNSFrameCorrectionProvider.h"
|
||||
#import "Swift-Bridging.h"
|
||||
@@ -43,6 +48,12 @@
|
||||
namespace react = facebook::react;
|
||||
#endif // RCT_NEW_ARCH_ENABLED
|
||||
|
||||
+#if TARGET_OS_TV
|
||||
+@interface RNSNavigationController ()
|
||||
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
|
||||
+@end
|
||||
+#endif // TARGET_OS_TV
|
||||
+
|
||||
@interface RNSScreenStackView () <
|
||||
UINavigationControllerDelegate,
|
||||
UIAdaptivePresentationControllerDelegate,
|
||||
@@ -62,6 +73,57 @@ namespace react = facebook::react;
|
||||
|
||||
@implementation RNSNavigationController
|
||||
|
||||
+#if TARGET_OS_TV
|
||||
+- (void)viewDidLoad
|
||||
+{
|
||||
+ [super viewDidLoad];
|
||||
+
|
||||
+ self.rnscreens_menuGestureRecognizer =
|
||||
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
|
||||
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
|
||||
+
|
||||
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||
+ selector:@selector(rnscreens_enableMenuGesture)
|
||||
+ name:RCTTVEnableMenuKeyNotification
|
||||
+ object:nil];
|
||||
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||
+ selector:@selector(rnscreens_disableMenuGesture)
|
||||
+ name:RCTTVDisableMenuKeyNotification
|
||||
+ object:nil];
|
||||
+
|
||||
+ if ([RCTTVRemoteHandler useMenuKey]) {
|
||||
+ [self rnscreens_enableMenuGesture];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)dealloc
|
||||
+{
|
||||
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
+}
|
||||
+
|
||||
+- (void)rnscreens_enableMenuGesture
|
||||
+{
|
||||
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)rnscreens_disableMenuGesture
|
||||
+{
|
||||
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
|
||||
+{
|
||||
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
|
||||
+ keyAction:recognizer.eventKeyAction
|
||||
+ tag:nil
|
||||
+ target:nil];
|
||||
+}
|
||||
+#endif // TARGET_OS_TV
|
||||
+
|
||||
#if !TARGET_OS_TV
|
||||
- (UIViewController *)childViewControllerForStatusBarStyle
|
||||
{
|
||||
diff --git a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||
index 95c76ccf3528d3a8828e90b272a1d79b0828a139..f29d4df21440d23523ae7a2f6fe71c32154e3928 100644
|
||||
--- a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||
+++ b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||
@@ -79,11 +79,13 @@ class RNSSplitViewAppearanceApplicator {
|
||||
maxWidth: splitView.maximumSupplementaryColumnWidth)
|
||||
|
||||
#if compiler(>=6.2)
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
validateColumnConstraints(
|
||||
minWidth: splitView.minimumInspectorColumnWidth,
|
||||
maxWidth: splitView.maximumInspectorColumnWidth)
|
||||
}
|
||||
+ #endif
|
||||
#endif
|
||||
|
||||
// Step 2.2 - applying updates to columns
|
||||
@@ -126,6 +128,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
if splitView.minimumSecondaryColumnWidth >= 0 {
|
||||
splitViewController.minimumSecondaryColumnWidth = splitView.minimumSecondaryColumnWidth
|
||||
@@ -159,6 +162,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||
splitView.preferredInspectorColumnWidthOrFraction
|
||||
}
|
||||
}
|
||||
+ #endif
|
||||
#endif
|
||||
|
||||
// Step 2.3 - manipulating with inspector column
|
||||
diff --git a/ios/gamma/split-view/RNSSplitViewHostController.swift b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||
index 0421e3ea92fc7bcdf57417b5ee3a62348fce34f5..cd878ab638d3c78a661e2df4c4c1b21011dfcf48 100644
|
||||
--- a/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||
+++ b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||
@@ -386,7 +386,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
/// @param inspectors An array of inspector-type RNSSplitViewScreenComponentView subviews.
|
||||
///
|
||||
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
|
||||
-
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
let inspector = inspectors.first
|
||||
if inspector != nil {
|
||||
@@ -395,6 +395,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
setViewController(inspectorViewController, for: .inspector)
|
||||
}
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
|
||||
///
|
||||
@@ -404,9 +405,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
|
||||
///
|
||||
func maybeShowInspector() {
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
show(.inspector)
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
|
||||
///
|
||||
@@ -416,9 +419,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
|
||||
///
|
||||
func maybeHideInspector() {
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
hide(.inspector)
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -444,6 +449,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||
public func splitViewController(
|
||||
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
|
||||
) {
|
||||
+ #if !os(tvOS)
|
||||
if #available(iOS 26.0, *) {
|
||||
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
|
||||
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
|
||||
@@ -461,6 +467,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
+ #endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -199,9 +200,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
}
|
||||
const downloadDetailsPromises = items.map(async (item) => {
|
||||
// Ensure the snapshot we store offline carries the Chapters array.
|
||||
// Page-level fetches sometimes use a fields filter that omits it; the
|
||||
// offline player would then render no chapter ticks / list.
|
||||
let itemForDownload = item;
|
||||
if (!itemForDownload.Chapters && itemForDownload.Id) {
|
||||
try {
|
||||
const enriched = await getUserLibraryApi(api).getItem({
|
||||
itemId: itemForDownload.Id,
|
||||
userId: user.Id!,
|
||||
});
|
||||
if (enriched.data) {
|
||||
itemForDownload = enriched.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { mediaSource, audioIndex, subtitleIndex } =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!)
|
||||
? getDefaultPlaySettings(itemForDownload, settings!)
|
||||
: {
|
||||
mediaSource: selectedOptions?.mediaSource,
|
||||
audioIndex: selectedOptions?.audioIndex,
|
||||
@@ -210,7 +232,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
const downloadDetails = await getDownloadUrl({
|
||||
api,
|
||||
item,
|
||||
item: itemForDownload,
|
||||
userId: user.Id!,
|
||||
mediaSource: mediaSource!,
|
||||
audioStreamIndex: audioIndex ?? -1,
|
||||
@@ -222,7 +244,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
|
||||
return {
|
||||
url: downloadDetails?.url,
|
||||
item,
|
||||
item: itemForDownload,
|
||||
mediaSource: downloadDetails?.mediaSource,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
Button,
|
||||
Host,
|
||||
Menu,
|
||||
Picker,
|
||||
Text as SwiftUIText,
|
||||
} from "@expo/ui/swift-ui";
|
||||
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import React, { useEffect, useState } from "react";
|
||||
@@ -20,6 +12,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
|
||||
// load and crashes the entire route tree on tvOS (expo-router requires every
|
||||
// route file). Load it lazily and only off-TV; TV never renders these.
|
||||
const { Button, Host, Menu } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { disabled } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
|
||||
// Option types
|
||||
export type RadioOption<T = any> = {
|
||||
type: "radio";
|
||||
@@ -261,25 +264,29 @@ const PlatformDropdownComponent = ({
|
||||
}
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
||||
// fills its parent and reports its own size via setStyleSize, so it can't
|
||||
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
||||
// height depends on the parent while the parent depends on the Host — a
|
||||
// circular dependency that collapses to 0 for any selector nested more than
|
||||
// one level deep (so only the first, shallowest dropdown stays visible).
|
||||
// Giving the wrapper the measured size breaks the cycle; the Host then
|
||||
// fills a concrete box.
|
||||
return (
|
||||
<View>
|
||||
{/* Hidden measurer: lays the trigger out normally to capture its
|
||||
intrinsic size, which we then pin onto the Host below. */}
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
|
||||
<View
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onLayout={handleMeasureTrigger}
|
||||
>
|
||||
{trigger}
|
||||
</View>
|
||||
</View>
|
||||
<Host
|
||||
style={[
|
||||
triggerSize ?? { opacity: 0 },
|
||||
expoUIConfig?.hostStyle as any,
|
||||
]}
|
||||
<View style={triggerSize ?? { opacity: 0 }}>
|
||||
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||
sizes to the trigger's content rather than to its parent. */}
|
||||
<View
|
||||
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||
pointerEvents='none'
|
||||
aria-hidden
|
||||
onLayout={handleMeasureTrigger}
|
||||
>
|
||||
{trigger}
|
||||
</View>
|
||||
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||
<Menu label={trigger}>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
@@ -295,41 +302,40 @@ const PlatformDropdownComponent = ({
|
||||
|
||||
const items = [];
|
||||
|
||||
// Add Picker for radio options ONLY if there's a group title
|
||||
// Group radio options under a submenu ONLY if there's a title
|
||||
// Otherwise render as individual buttons
|
||||
if (radioOptions.length > 0) {
|
||||
if (group.title) {
|
||||
// Use Picker for grouped options.
|
||||
// Use the option index (a stable primitive) as the
|
||||
// tag/selection value and React key. Option `value`s can be
|
||||
// objects (e.g. bitrate / media source), which collapse to
|
||||
// "[object Object]" as a key and never match the Picker's
|
||||
// primitive selection.
|
||||
const selectedRadioIndex = radioOptions.findIndex(
|
||||
// Use a nested Menu as a submenu for grouped options. This
|
||||
// reads as "Title: Selected" and expands to the choices on
|
||||
// tap, keeping the nested look while staying a dropdown.
|
||||
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
||||
// would require a long-press and read as a context menu.)
|
||||
const selectedOption = radioOptions.find(
|
||||
(opt) => opt.selected,
|
||||
);
|
||||
const displayTitle = selectedOption
|
||||
? `${group.title}: ${selectedOption.label}`
|
||||
: group.title;
|
||||
items.push(
|
||||
<Picker
|
||||
key={`picker-${groupIndex}`}
|
||||
label={group.title}
|
||||
selection={
|
||||
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
|
||||
}
|
||||
onSelectionChange={(index) => {
|
||||
const selectedOption = radioOptions[index as number];
|
||||
selectedOption?.onPress();
|
||||
onOptionSelect?.(selectedOption?.value);
|
||||
}}
|
||||
>
|
||||
{radioOptions.map((opt, optionIndex) => (
|
||||
<SwiftUIText
|
||||
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
||||
{radioOptions.map((option, optionIndex) => (
|
||||
<Button
|
||||
key={`radio-${groupIndex}-${optionIndex}`}
|
||||
modifiers={[tag(optionIndex)]}
|
||||
>
|
||||
{opt.label}
|
||||
</SwiftUIText>
|
||||
label={option.label}
|
||||
systemImage={
|
||||
option.selected ? "checkmark.circle.fill" : "circle"
|
||||
}
|
||||
modifiers={
|
||||
option.disabled ? [disabled(true)] : undefined
|
||||
}
|
||||
onPress={() => {
|
||||
option.onPress();
|
||||
onOptionSelect?.(option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Picker>,
|
||||
</Menu>,
|
||||
);
|
||||
} else {
|
||||
// Render radio options as direct buttons
|
||||
|
||||
@@ -30,6 +30,7 @@ import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
@@ -67,6 +68,14 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
|
||||
// SyncPlay: when enabled, we DO NOT navigate locally on play — we tell the
|
||||
// server, which broadcasts a PlayQueue: NewPlaylist update to every group
|
||||
// member (including us). Our `setStartPlaybackHandler` in SyncPlayProvider
|
||||
// then performs the navigation uniformly for everyone, matching
|
||||
// jellyfin-web's playbackManager intercept (Controller.play).
|
||||
const { isEnabled: isSyncPlayEnabled, controller: syncPlayController } =
|
||||
useSyncPlay();
|
||||
|
||||
// Use colors prop if provided, otherwise fallback to global atom
|
||||
const effectiveColors = colors || globalColorAtom;
|
||||
|
||||
@@ -94,6 +103,37 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
// SyncPlay intercept: in a group, route playback through sthe server so
|
||||
// every member gets the same PlayQueue: NewPlaylist update and navigates
|
||||
// together. Skips local navigation and the Chromecast prompt entirely —
|
||||
// SyncPlay + Chromecast isn't a supported combination yet, same as
|
||||
// jellyfin-web.
|
||||
if (isSyncPlayEnabled && syncPlayController && item.Id) {
|
||||
try {
|
||||
// Pass the full `item` (not just the ID) so the SyncPlay controller
|
||||
// can run `translateItemsForPlayback` with full context — this is
|
||||
// what jellyfin-web does, and it lets us expand Series / Season /
|
||||
// BoxSet into real episode/track IDs before broadcasting the queue.
|
||||
// Without expansion, receivers (jellyfin-web in particular) get
|
||||
// container IDs they can't play and silently fail to open the
|
||||
// player.
|
||||
await syncPlayController.play({
|
||||
items: [item],
|
||||
ids: [item.Id],
|
||||
startPositionTicks: item.UserData?.PlaybackPositionTicks ?? 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("SyncPlay: failed to start group playback", error);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("syncplay.failed_to_start", {
|
||||
defaultValue: "Failed to start SyncPlay group playback",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
@@ -290,6 +330,8 @@ export const PlayButton: React.FC<Props> = ({
|
||||
goToPlayer,
|
||||
isOffline,
|
||||
t,
|
||||
isSyncPlayEnabled,
|
||||
syncPlayController,
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
@@ -414,7 +456,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
]);
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
if (!item?.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
|
||||
@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
if (!item || !item.RunTimeTicks) return 0;
|
||||
if (!item?.RunTimeTicks) return 0;
|
||||
const userData = item.UserData;
|
||||
if (userData?.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
|
||||
196
components/chapters/ChapterList.tsx
Normal file
196
components/chapters/ChapterList.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* A modal listing an item's chapters. Each row shows the chapter name and its
|
||||
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
|
||||
* chapter and closes the modal. Player-agnostic — the seek is injected.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { memo, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
type ChapterEntry,
|
||||
chapterStartsMs,
|
||||
formatChapterTime,
|
||||
sortedChapters,
|
||||
} from "@/utils/chapters";
|
||||
|
||||
interface ChapterListProps {
|
||||
visible: boolean;
|
||||
chapters: ChapterInfo[] | null | undefined;
|
||||
/** Current playback position in milliseconds (to highlight the row). */
|
||||
currentPositionMs: number;
|
||||
/** Seek the player to this millisecond position. */
|
||||
onSeek: (positionMs: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 48;
|
||||
|
||||
function ChapterListComponent({
|
||||
visible,
|
||||
chapters,
|
||||
currentPositionMs,
|
||||
onSeek,
|
||||
onClose,
|
||||
}: ChapterListProps) {
|
||||
const { t } = useTranslation();
|
||||
const listRef = useRef<FlatList<ChapterEntry>>(null);
|
||||
|
||||
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
|
||||
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
|
||||
// every tick — chapters is the only input that drives the underlying array.
|
||||
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
|
||||
const activeIndex = useMemo(() => {
|
||||
let idx = -1;
|
||||
for (let i = 0; i < starts.length; i++) {
|
||||
if (currentPositionMs >= starts[i]) idx = i;
|
||||
else break;
|
||||
}
|
||||
return idx;
|
||||
}, [currentPositionMs, starts]);
|
||||
|
||||
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
|
||||
// children mounted across visible toggles, so subsequent opens never scroll.
|
||||
// Trigger an imperative scroll each time the sheet becomes visible.
|
||||
useEffect(() => {
|
||||
if (!visible || activeIndex < 0 || entries.length === 0) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: activeIndex,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [visible, activeIndex, entries.length]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType='slide'
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable onPress={onClose} style={styles.backdrop}>
|
||||
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{t("chapters.title")}</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.close")}
|
||||
>
|
||||
<Ionicons name='close' size={24} color={Colors.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={entries}
|
||||
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: ROW_HEIGHT,
|
||||
offset: ROW_HEIGHT * index,
|
||||
index,
|
||||
})}
|
||||
onScrollToIndexFailed={(info) => {
|
||||
// Required when getItemLayout is provided and the target index
|
||||
// is outside the currently rendered window. Fallback to an
|
||||
// offset-based scroll, then retry the precise scroll once a
|
||||
// frame has elapsed.
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: info.averageItemLength * info.index,
|
||||
animated: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
listRef.current?.scrollToIndex({
|
||||
index: info.index,
|
||||
animated: false,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, 50);
|
||||
}}
|
||||
renderItem={({ item, index }) => {
|
||||
const positionMs = item.positionMs;
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onSeek(positionMs);
|
||||
onClose();
|
||||
}}
|
||||
style={[
|
||||
styles.row,
|
||||
isActive && { backgroundColor: `${Colors.primary}33` },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.rowText,
|
||||
{ color: isActive ? Colors.primary : Colors.text },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.chapter.Name ||
|
||||
t("chapters.chapter_number", { number: index + 1 })}
|
||||
</Text>
|
||||
<Text style={styles.rowTime}>
|
||||
{formatChapterTime(positionMs)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChapterList = memo(ChapterListComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "rgba(0,0,0,0.6)",
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: Colors.background,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: "70%",
|
||||
paddingBottom: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
color: Colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
height: ROW_HEIGHT,
|
||||
},
|
||||
rowText: {
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
},
|
||||
rowTime: {
|
||||
color: Colors.icon,
|
||||
fontSize: 13,
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
87
components/chapters/ChapterTicks.tsx
Normal file
87
components/chapters/ChapterTicks.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Chapter tick marks drawn as an absolute overlay over a progress slider.
|
||||
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
|
||||
* so the slider underneath still receives touches.
|
||||
*/
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
|
||||
import type { ChapterMarker } from "@/utils/chapters";
|
||||
|
||||
interface ChapterTicksProps {
|
||||
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
|
||||
markers: ChapterMarker[];
|
||||
/** Tick colour. */
|
||||
color?: string;
|
||||
/** Tick height in px — slightly less than the slider track thickness. */
|
||||
height?: number;
|
||||
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function ChapterTicksComponent({
|
||||
markers,
|
||||
// Semi-transparent black contrasts against both the filled progress
|
||||
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
|
||||
// stay visible across the whole bar as playback advances.
|
||||
color = "rgba(0,0,0,0.55)",
|
||||
height = 14,
|
||||
width = 2,
|
||||
}: ChapterTicksProps) {
|
||||
// Hooks must run unconditionally — keep them before any early return.
|
||||
const [sliderWidth, setSliderWidth] = useState(0);
|
||||
|
||||
const handleLayout = (e: LayoutChangeEvent) => {
|
||||
setSliderWidth(e.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
// One chapter (typically a single marker at 0) is not worth marking.
|
||||
if (markers.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
pointerEvents='none'
|
||||
onLayout={handleLayout}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
// Let ticks taller than this container bleed beyond its bounds.
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{sliderWidth > 0 &&
|
||||
markers
|
||||
// Skip the leading 0ms marker — it overlaps the slider start and
|
||||
// adds visual noise at an already-rendered boundary.
|
||||
.filter((marker) => marker.positionMs > 0)
|
||||
.map((marker, index) => {
|
||||
// Align both the position AND the width onto the device's
|
||||
// physical pixel grid. Without this, fractional dp values land
|
||||
// at different sub-pixel fractions per tick — Android samples
|
||||
// each one differently and some ticks render visibly thicker.
|
||||
const centerDp = (marker.percent / 100) * sliderWidth;
|
||||
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
|
||||
const snappedWidth = PixelRatio.roundToNearestPixel(width);
|
||||
return (
|
||||
<View
|
||||
key={`${marker.positionMs}-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top: "50%",
|
||||
marginTop: -height / 2,
|
||||
height,
|
||||
width: snappedWidth,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChapterTicks = memo(ChapterTicksComponent);
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type TextInputProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
interface InputProps extends TextInputProps {
|
||||
extraClassName?: string;
|
||||
@@ -20,6 +21,9 @@ export function Input(props: InputProps) {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
// TV-only: scales the input font with the tvTypographyScale setting.
|
||||
// Not consumed by the mobile branch below.
|
||||
const tvTypography = useScaledTVTypography();
|
||||
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.timing(scale, {
|
||||
@@ -41,8 +45,18 @@ export function Input(props: InputProps) {
|
||||
};
|
||||
|
||||
if (Platform.isTV) {
|
||||
// Scale the whole input (box height, padding, icon) proportionally with the
|
||||
// font so the component grows/shrinks with the tvTypographyScale setting.
|
||||
// Uses the `body` token (primary reading size); it resolves to 28 at Default.
|
||||
const fontSize = tvTypography.body;
|
||||
const factor = fontSize / 28;
|
||||
const height = Math.round(56 * factor);
|
||||
const paddingLeft = Math.round(24 * factor);
|
||||
const iconSize = Math.round(26 * factor);
|
||||
const iconMarginRight = Math.round(14 * factor);
|
||||
|
||||
const containerStyle = {
|
||||
height: 48,
|
||||
height,
|
||||
borderRadius: 50,
|
||||
borderWidth: isFocused ? 1.5 : 1,
|
||||
borderColor: isFocused
|
||||
@@ -51,16 +65,16 @@ export function Input(props: InputProps) {
|
||||
overflow: "hidden" as const,
|
||||
flexDirection: "row" as const,
|
||||
alignItems: "center" as const,
|
||||
paddingLeft: 16,
|
||||
paddingLeft,
|
||||
};
|
||||
|
||||
const inputElement = (
|
||||
<>
|
||||
<Ionicons
|
||||
name='search'
|
||||
size={20}
|
||||
size={iconSize}
|
||||
color={isFocused ? "#999" : "#666"}
|
||||
style={{ marginRight: 12 }}
|
||||
style={{ marginRight: iconMarginRight }}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
@@ -69,8 +83,8 @@ export function Input(props: InputProps) {
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
height: 48,
|
||||
fontSize: 18,
|
||||
height,
|
||||
fontSize,
|
||||
fontWeight: "400",
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "transparent",
|
||||
|
||||
@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}, [process?.progress]);
|
||||
|
||||
// Return null after all hooks have been called
|
||||
if (!process || !process.item || !process.item.Id) {
|
||||
if (!process?.item?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||
@@ -89,6 +90,10 @@ const HomeMobile = () => {
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
const { showIntro } = useIntroSheet();
|
||||
|
||||
// Fallback refresh for newly added content when returning to the home screen
|
||||
// (primary path is the LibraryChanged WebSocket event).
|
||||
useRefreshLibraryOnFocus();
|
||||
|
||||
// Show intro modal on first launch
|
||||
useEffect(() => {
|
||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Loader } from "@/components/Loader";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import {
|
||||
@@ -86,6 +87,10 @@ export const Home = () => {
|
||||
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const { showItemActions } = useTVItemActionModal();
|
||||
|
||||
// Fallback refresh for newly added content when returning to the home screen
|
||||
// (primary path is the LibraryChanged WebSocket event).
|
||||
useRefreshLibraryOnFocus();
|
||||
|
||||
// Dynamic backdrop state with debounce
|
||||
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
|
||||
// Get subtitle for episodes
|
||||
const episodeSubtitle = useMemo(() => {
|
||||
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||
if (activeItem?.Type !== "Episode") return null;
|
||||
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||
}, [activeItem]);
|
||||
|
||||
|
||||
@@ -180,4 +180,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export { CARD_WIDTH, CARD_HEIGHT };
|
||||
export { CARD_HEIGHT, CARD_WIDTH };
|
||||
|
||||
@@ -155,7 +155,7 @@ export const TVLiveTVGuide: React.FC = () => {
|
||||
);
|
||||
|
||||
// Fetch programs for visible channels
|
||||
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
|
||||
const { data: programsData } = useQuery({
|
||||
queryKey: [
|
||||
"livetv",
|
||||
"tv-guide",
|
||||
|
||||
@@ -382,16 +382,18 @@ export const Login: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||
</View>
|
||||
) : (
|
||||
<View className='flex flex-col flex-1 w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full'>
|
||||
<View className='flex flex-col flex-1 items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginLeft: -23,
|
||||
marginBottom: -20,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
source={require("@/assets/images/icon-ios-plain.png")}
|
||||
/>
|
||||
@@ -429,8 +431,6 @@ export const Login: React.FC = () => {
|
||||
await handleConnect(server.address);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className='px-4 pb-2'>
|
||||
<PreviousServersList
|
||||
onServerSelect={async (s) => {
|
||||
await handleConnect(s.address);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
|
||||
interface TVPasswordEntryModalProps {
|
||||
@@ -201,6 +202,13 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||
setIsReady(false);
|
||||
}, [visible]);
|
||||
|
||||
// Close the modal on the TV remote back/menu button while it is open.
|
||||
useTVBackPress(() => {
|
||||
if (!visible) return false;
|
||||
onClose();
|
||||
return true;
|
||||
}, [visible, onClose]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password) {
|
||||
setError(t("password.enter_password"));
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import {
|
||||
Button,
|
||||
ContextMenu,
|
||||
Host,
|
||||
Picker,
|
||||
Text as SwiftUIText,
|
||||
} from "@expo/ui/swift-ui";
|
||||
import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Platform, View } from "react-native";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import crashes the route tree on tvOS at module load.
|
||||
// Load it lazily and only off-TV; TV never renders this component.
|
||||
const { Button, Host, Menu } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { buttonStyle } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
|
||||
interface DiscoverFiltersProps {
|
||||
searchFilterId: string;
|
||||
orderFilterId: string;
|
||||
@@ -35,7 +37,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
setJellyseerrSortOrder,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
@@ -47,42 +49,54 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<Menu
|
||||
label={
|
||||
<Button
|
||||
modifiers={[buttonStyle("glass")]}
|
||||
systemImage='line.3.horizontal.decrease.circle'
|
||||
></Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
<Picker
|
||||
label={t("library.filters.sort_by")}
|
||||
selection={jellyseerrOrderBy as unknown as string}
|
||||
onSelectionChange={(value) => {
|
||||
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
|
||||
}}
|
||||
>
|
||||
{sortOptions.map((item) => (
|
||||
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
||||
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
||||
</SwiftUIText>
|
||||
))}
|
||||
</Picker>
|
||||
<Picker
|
||||
label={t("library.filters.sort_order")}
|
||||
selection={jellyseerrSortOrder}
|
||||
onSelectionChange={(value) => {
|
||||
setJellyseerrSortOrder(value as "asc" | "desc");
|
||||
}}
|
||||
>
|
||||
{orderOptions.map((item) => (
|
||||
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
||||
{t(`library.filters.${item}`)}
|
||||
</SwiftUIText>
|
||||
))}
|
||||
</Picker>
|
||||
</ContextMenu.Items>
|
||||
</ContextMenu>
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu
|
||||
label={`${t("library.filters.sort_by")}: ${t(
|
||||
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
|
||||
)}`}
|
||||
>
|
||||
{sortOptions.map((item) => {
|
||||
const isSelected =
|
||||
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
||||
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
||||
onPress={() =>
|
||||
setJellyseerrOrderBy(
|
||||
item as unknown as JellyseerrSearchSort,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
<Menu
|
||||
label={`${t("library.filters.sort_order")}: ${t(
|
||||
`library.filters.${jellyseerrSortOrder}`,
|
||||
)}`}
|
||||
>
|
||||
{orderOptions.map((item) => {
|
||||
const isSelected = jellyseerrSortOrder === item;
|
||||
return (
|
||||
<Button
|
||||
key={item}
|
||||
label={t(`library.filters.${item}`)}
|
||||
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
||||
onPress={() => setJellyseerrSortOrder(item)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</Menu>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Button, Host } from "@expo/ui/swift-ui";
|
||||
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
|
||||
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
|
||||
// A static top-level import crashes the route tree on tvOS at module load.
|
||||
// Load it lazily and only off-TV; TV never renders this component.
|
||||
const { Button, Host, HStack, Spacer } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui"))
|
||||
: require("@expo/ui/swift-ui");
|
||||
const { buttonStyle } = Platform.isTV
|
||||
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
|
||||
: require("@expo/ui/swift-ui/modifiers");
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
interface SearchTabButtonsProps {
|
||||
@@ -17,18 +24,10 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||
setSearchType,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<>
|
||||
<Host
|
||||
style={{
|
||||
height: 40,
|
||||
width: 80,
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Host style={{ height: 40, flex: 1 }}>
|
||||
<HStack spacing={8}>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
@@ -36,19 +35,8 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||
),
|
||||
]}
|
||||
onPress={() => setSearchType("Library")}
|
||||
>
|
||||
<Text>{t("search.library")}</Text>
|
||||
</Button>
|
||||
</Host>
|
||||
<Host
|
||||
style={{
|
||||
height: 40,
|
||||
width: 100,
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
label={t("search.library")}
|
||||
/>
|
||||
<Button
|
||||
modifiers={[
|
||||
buttonStyle(
|
||||
@@ -56,11 +44,11 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
||||
),
|
||||
]}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
>
|
||||
<Text>{t("search.discover")}</Text>
|
||||
</Button>
|
||||
</Host>
|
||||
</>
|
||||
label={t("search.discover")}
|
||||
/>
|
||||
<Spacer />
|
||||
</HStack>
|
||||
</Host>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { TvSearchView } from "@/modules/tv-search";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
@@ -16,36 +17,46 @@ import type {
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
||||
import { TVSearchSection } from "./TVSearchSection";
|
||||
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
||||
// its own overlay when the field is focused, so we only reserve the bar height
|
||||
// here — not the whole keyboard. Tunable once seen on device.
|
||||
const SEARCH_AREA_HEIGHT = 250;
|
||||
const SECTION_GAP = 10;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
// Loading skeleton for TV
|
||||
// Loading skeleton for TV.
|
||||
// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
|
||||
// padding, heading typography, poster radius) so the placeholder lines up with
|
||||
// the real content that replaces it.
|
||||
const TVLoadingSkeleton: React.FC = () => {
|
||||
const typography = useScaledTVTypography();
|
||||
const itemWidth = 210;
|
||||
const sizes = useScaledTVSizes();
|
||||
const itemWidth = sizes.posters.poster;
|
||||
return (
|
||||
<View style={{ overflow: "visible" }}>
|
||||
{/* Section header placeholder — matches the heading typography + margins */}
|
||||
<View
|
||||
style={{
|
||||
width: 200,
|
||||
height: 28,
|
||||
width: itemWidth,
|
||||
height: typography.heading,
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: 20,
|
||||
marginLeft: sizes.padding.horizontal,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
gap: sizes.gaps.item,
|
||||
paddingLeft: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
@@ -56,15 +67,14 @@ const TVLoadingSkeleton: React.FC = () => {
|
||||
backgroundColor: "#262626",
|
||||
width: itemWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
borderRadius: scaleSize(24),
|
||||
marginBottom: scaleSize(8),
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
marginBottom: 4,
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
@@ -124,7 +134,6 @@ interface TVSearchPageProps {
|
||||
}
|
||||
|
||||
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
search,
|
||||
setSearch,
|
||||
debouncedSearch,
|
||||
movies,
|
||||
@@ -215,125 +224,129 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Sticky header: search field stays pinned while results scroll below. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING + 200,
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
hasTVPreferredFocus={
|
||||
debouncedSearch.length === 0 &&
|
||||
sections.length === 0 &&
|
||||
!showDiscover
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<TvSearchView
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Loading State */}
|
||||
{currentLoading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
["artists", "albums", "songs", "playlists"].includes(
|
||||
section.key,
|
||||
)
|
||||
? getImageUrl
|
||||
: undefined
|
||||
}
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
paddingBottom: insets.bottom + 60,
|
||||
}}
|
||||
>
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Jellyseerr/Discover Search Results */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||
<TVJellyseerrSearchResults
|
||||
movieResults={jellyseerrMovies}
|
||||
tvResults={jellyseerrTv}
|
||||
personResults={jellyseerrPersons}
|
||||
loading={jellyseerrLoading}
|
||||
noResults={jellyseerrNoResults}
|
||||
searchQuery={debouncedSearch}
|
||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||
/>
|
||||
)}
|
||||
{/* Loading State */}
|
||||
{currentLoading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Discover Content (when no search query in Discover mode) */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
|
||||
<TVDiscover sliders={discoverSliders} />
|
||||
)}
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
["artists", "albums", "songs", "playlists"].includes(
|
||||
section.key,
|
||||
)
|
||||
? getImageUrl
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
{/* Jellyseerr/Discover Search Results */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||
<TVJellyseerrSearchResults
|
||||
movieResults={jellyseerrMovies}
|
||||
tvResults={jellyseerrTv}
|
||||
personResults={jellyseerrPersons}
|
||||
loading={jellyseerrLoading}
|
||||
noResults={jellyseerrNoResults}
|
||||
searchQuery={debouncedSearch}
|
||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discover Content (when no search query in Discover mode) */}
|
||||
{isDiscoverMode &&
|
||||
!jellyseerrLoading &&
|
||||
debouncedSearch.length === 0 && (
|
||||
<TVDiscover sliders={discoverSliders} />
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ interface TVSearchSectionProps extends ViewProps {
|
||||
onItemPress: (item: BaseItemDto) => void;
|
||||
onItemLongPress?: (item: BaseItemDto) => void;
|
||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||
/** Override the horizontal edge padding (defaults to the scaled TV padding). */
|
||||
horizontalPadding?: number;
|
||||
}
|
||||
|
||||
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
@@ -31,12 +33,14 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
onItemPress,
|
||||
onItemLongPress,
|
||||
imageUrlGetter,
|
||||
horizontalPadding,
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const posterSizes = useScaledTVPosterSizes();
|
||||
const sizes = useScaledTVSizes();
|
||||
const ITEM_GAP = sizes.gaps.item;
|
||||
const edgePadding = horizontalPadding ?? sizes.padding.horizontal;
|
||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||
const [focusedCount, setFocusedCount] = useState(0);
|
||||
const prevFocusedCount = useRef(0);
|
||||
@@ -273,7 +277,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 20,
|
||||
marginLeft: sizes.padding.horizontal,
|
||||
marginLeft: edgePadding,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
@@ -294,10 +298,10 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible" }}
|
||||
contentInset={{
|
||||
left: sizes.padding.horizontal,
|
||||
right: sizes.padding.horizontal,
|
||||
left: edgePadding,
|
||||
right: edgePadding,
|
||||
}}
|
||||
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
|
||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
|
||||
@@ -193,7 +193,7 @@ const TVSeasonButton: React.FC<{
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "#FFFFFF",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
@@ -538,7 +538,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
}}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import type { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||
import { Stack } from "expo-router";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||
|
||||
type ICommonScreenOptions =
|
||||
| NativeStackNavigationOptions
|
||||
| ((prop: {
|
||||
route: RouteProp<ParamListBase, string>;
|
||||
navigation: any;
|
||||
}) => NativeStackNavigationOptions);
|
||||
type ICommonScreenOptions = ComponentProps<typeof Stack.Screen>["options"];
|
||||
|
||||
export const commonScreenOptions: ICommonScreenOptions = {
|
||||
title: "",
|
||||
|
||||
235
components/syncplay/GroupSelectionMenu.tsx
Normal file
235
components/syncplay/GroupSelectionMenu.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* GroupSelectionMenu
|
||||
*
|
||||
* Content rendered inside the SyncPlay bottom sheet (the sheet itself is
|
||||
* owned by SyncPlayButton). Calls `onClose` after successful actions to
|
||||
* dismiss the parent sheet.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||
import type { GroupInfoDto } from "@/providers/SyncPlay/types";
|
||||
|
||||
interface GroupSelectionMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GroupSelectionMenu({ onClose }: GroupSelectionMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const {
|
||||
isEnabled,
|
||||
groupInfo,
|
||||
canCreateGroups,
|
||||
joinGroup,
|
||||
createGroup,
|
||||
leaveGroup,
|
||||
getGroups,
|
||||
} = useSyncPlay();
|
||||
|
||||
const [groups, setGroups] = useState<GroupInfoDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const fetchedGroups = await getGroups();
|
||||
if (!cancelled) {
|
||||
setGroups(fetchedGroups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch groups", error);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [getGroups]);
|
||||
|
||||
const handleJoinGroup = useCallback(
|
||||
async (groupId: string) => {
|
||||
try {
|
||||
await joinGroup(groupId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to join group", error);
|
||||
}
|
||||
},
|
||||
[joinGroup, onClose],
|
||||
);
|
||||
|
||||
const handleCreateGroup = useCallback(async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createGroup();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create group", error);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [createGroup, onClose]);
|
||||
|
||||
const handleLeaveGroup = useCallback(async () => {
|
||||
try {
|
||||
await leaveGroup();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to leave group", error);
|
||||
}
|
||||
}, [leaveGroup, onClose]);
|
||||
|
||||
const containerStyle = {
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
paddingTop: 8,
|
||||
};
|
||||
|
||||
if (isEnabled && groupInfo) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View className='mb-4'>
|
||||
<View className='flex-row items-center mb-2'>
|
||||
<Ionicons name='people' size={24} color='#00a4dc' />
|
||||
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||
{t("syncplay.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-neutral-400'>{t("syncplay.my_group")}</Text>
|
||||
</View>
|
||||
|
||||
<View className='bg-neutral-800 rounded-xl p-4 mb-4'>
|
||||
<View className='flex-row items-center justify-between mb-3'>
|
||||
<Text className='text-neutral-100 font-semibold text-lg'>
|
||||
{groupInfo.GroupName}
|
||||
</Text>
|
||||
<View className='bg-[#00a4dc] px-2 py-1 rounded'>
|
||||
<Text className='text-white text-xs font-medium'>
|
||||
{groupInfo.State}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{groupInfo.Participants && groupInfo.Participants.length > 0 && (
|
||||
<View className='flex-row items-center'>
|
||||
<Ionicons name='person' size={16} color='#9ca3af' />
|
||||
<Text className='text-neutral-400 ml-2'>
|
||||
{groupInfo.Participants.length} {t("syncplay.members")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button onPress={handleLeaveGroup} color='red'>
|
||||
<View className='flex-row items-center justify-center'>
|
||||
<Ionicons name='exit-outline' size={20} color='white' />
|
||||
<Text className='text-white font-semibold ml-2'>
|
||||
{t("syncplay.leave_group")}
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View className='mb-4'>
|
||||
<View className='flex-row items-center mb-2'>
|
||||
<Ionicons name='people-outline' size={24} color='white' />
|
||||
<Text className='font-bold text-xl text-neutral-100 ml-2'>
|
||||
{t("syncplay.title")}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='text-neutral-400'>{t("syncplay.join_group")}</Text>
|
||||
</View>
|
||||
|
||||
{isLoading && (
|
||||
<View className='py-8 items-center'>
|
||||
<ActivityIndicator color='#00a4dc' />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLoading && groups.length > 0 && (
|
||||
<View className='mb-4'>
|
||||
<Text className='text-neutral-400 text-sm mb-2 ml-1'>
|
||||
{t("syncplay.available_groups")}
|
||||
</Text>
|
||||
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||
{groups.map((group, index) => (
|
||||
<TouchableOpacity
|
||||
key={group.GroupId ?? index}
|
||||
onPress={() => group.GroupId && handleJoinGroup(group.GroupId)}
|
||||
className={`flex-row items-center p-4 ${
|
||||
index < groups.length - 1 ? "border-b border-neutral-700" : ""
|
||||
}`}
|
||||
>
|
||||
<View className='w-10 h-10 bg-[#00a4dc]/20 rounded-full items-center justify-center mr-3'>
|
||||
<Ionicons name='people' size={20} color='#00a4dc' />
|
||||
</View>
|
||||
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-100 font-medium'>
|
||||
{group.GroupName}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{group.Participants?.length ?? 0} {t("syncplay.members")} •{" "}
|
||||
{group.State}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons name='chevron-forward' size={20} color='#9ca3af' />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLoading && groups.length === 0 && (
|
||||
<View className='bg-neutral-800/50 rounded-xl p-6 mb-4 items-center'>
|
||||
<Ionicons name='people-outline' size={40} color='#6b7280' />
|
||||
<Text className='text-neutral-400 text-center mt-3'>
|
||||
{t("syncplay.available_groups")}: 0{"\n"}
|
||||
{t("syncplay.create_new_group")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{canCreateGroups && (
|
||||
<Button
|
||||
onPress={handleCreateGroup}
|
||||
color='purple'
|
||||
disabled={isCreating}
|
||||
>
|
||||
<View className='flex-row items-center justify-center'>
|
||||
{isCreating ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold ml-2'>
|
||||
{t("syncplay.create_new_group")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
97
components/syncplay/SyncPlayButton.tsx
Normal file
97
components/syncplay/SyncPlayButton.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* SyncPlayButton
|
||||
*
|
||||
* Header button for accessing SyncPlay functionality.
|
||||
* Shows group status and opens the group selection sheet.
|
||||
*
|
||||
* Uses the @expo/ui drop-in BottomSheetModal (SwiftUI sheet on iOS, Jetpack
|
||||
* Compose ModalBottomSheet on Android). Because it presents natively, it
|
||||
* works correctly even when triggered from `headerRight` — no portal or
|
||||
* provider context is required (unlike @gorhom/bottom-sheet, which fails
|
||||
* silently from detached UINavigationItem subtrees).
|
||||
*
|
||||
* Safe to import statically: this whole module is lazy-required only on
|
||||
* non-TV platforms by app/(auth)/(tabs)/(home)/_layout.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
type BottomSheetMethods,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@expo/ui/community/bottom-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { toast } from "sonner-native";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay";
|
||||
import { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||
|
||||
interface SyncPlayButtonProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function SyncPlayButton({ size = 22 }: SyncPlayButtonProps) {
|
||||
const { isEnabled, canJoinGroups } = useSyncPlay();
|
||||
const { isConnected } = useNetworkStatus();
|
||||
const castDevice = useCastDevice();
|
||||
const sheetRef = useRef<BottomSheetMethods>(null);
|
||||
|
||||
const isCasting = !!castDevice;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isCasting) {
|
||||
toast("SyncPlay not available while casting");
|
||||
return;
|
||||
}
|
||||
sheetRef.current?.present();
|
||||
}, [isCasting]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
sheetRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
if (Platform.isTV) return null;
|
||||
if (!canJoinGroups) return null;
|
||||
if (!isConnected) return null;
|
||||
|
||||
const iconColor = isCasting ? "#6b7280" : isEnabled ? "#00a4dc" : "white";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
className='mr-4'
|
||||
onPress={handlePress}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<View className='relative'>
|
||||
<Ionicons
|
||||
name={isEnabled ? "people" : "people-outline"}
|
||||
size={size}
|
||||
color={iconColor}
|
||||
/>
|
||||
{isEnabled && !isCasting && (
|
||||
<View
|
||||
className='absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-[#00a4dc]'
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "#171717",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
<BottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={Platform.OS === "android" ? ["100%"] : ["60%"]}
|
||||
enablePanDownToClose
|
||||
>
|
||||
<BottomSheetView>
|
||||
<GroupSelectionMenu onClose={handleDismiss} />
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
components/syncplay/SyncPlayIndicator.tsx
Normal file
165
components/syncplay/SyncPlayIndicator.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* SyncPlayIndicator
|
||||
*
|
||||
* Visual indicator shown during SyncPlay operations.
|
||||
* Only appears when user's stream is ready but waiting for other group members.
|
||||
*
|
||||
* Key principle: SyncPlay indicator = "You're ready, waiting on others"
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
// SyncPlay cyan color (matches Jellyfin-web)
|
||||
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||
|
||||
interface SyncPlayIndicatorProps {
|
||||
/**
|
||||
* Whether the indicator should be visible.
|
||||
* Should only be true when:
|
||||
* 1. User's stream has loaded
|
||||
* 2. Waiting for other group members
|
||||
*/
|
||||
visible: boolean;
|
||||
|
||||
/**
|
||||
* Optional message to display
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function SyncPlayIndicator({
|
||||
visible,
|
||||
message,
|
||||
}: SyncPlayIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayMessage = message ?? t("syncplay.waiting_for_group");
|
||||
const opacity = useSharedValue(0);
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
opacity.value = withTiming(1, { duration: 200 });
|
||||
scale.value = withRepeat(
|
||||
withTiming(1.15, {
|
||||
duration: 800,
|
||||
easing: Easing.inOut(Easing.ease),
|
||||
}),
|
||||
-1,
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
scale.value = 1;
|
||||
}
|
||||
}, [visible, opacity, scale]);
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const pulseStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, containerStyle]}>
|
||||
<View style={styles.content}>
|
||||
{/* Pulsing icon container */}
|
||||
<Animated.View style={[styles.iconContainer, pulseStyle]}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name='people' size={28} color='white' />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={styles.message}>{displayMessage}</Text>
|
||||
|
||||
{/* SyncPlay badge */}
|
||||
<View style={styles.badge}>
|
||||
<Ionicons name='sync' size={12} color='white' />
|
||||
<Text style={styles.badgeText}>SyncPlay</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
zIndex: 100,
|
||||
},
|
||||
content: {
|
||||
alignItems: "center",
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: SYNC_PLAY_COLOR,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
// Glow effect
|
||||
shadowColor: SYNC_PLAY_COLOR,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
message: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
marginBottom: 8,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 164, 220, 0.2)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: SYNC_PLAY_COLOR,
|
||||
},
|
||||
badgeText: {
|
||||
color: SYNC_PLAY_COLOR,
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginLeft: 4,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Hook-compatible version that reads SyncPlay state directly
|
||||
*/
|
||||
export function useSyncPlayIndicatorState(
|
||||
isLocalReady: boolean,
|
||||
isGroupWaiting: boolean,
|
||||
): boolean {
|
||||
// Show indicator only when:
|
||||
// 1. User's local stream has loaded (isLocalReady)
|
||||
// 2. Group is still waiting for others (isGroupWaiting)
|
||||
return isLocalReady && isGroupWaiting;
|
||||
}
|
||||
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
53
components/syncplay/SyncPlaySpinner.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* SyncPlaySpinner
|
||||
*
|
||||
* Compact rotating SyncPlay icon shown in place of the play/pause button
|
||||
* while a play/pause command is in flight to the server (the "schedule-play"
|
||||
* indicator from jellyfin-web).
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect } from "react";
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
// SyncPlay cyan color (matches jellyfin-web)
|
||||
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||
|
||||
interface SyncPlaySpinnerProps {
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function SyncPlaySpinner({
|
||||
size,
|
||||
color = SYNC_PLAY_COLOR,
|
||||
}: SyncPlaySpinnerProps) {
|
||||
const rotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
rotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 1200,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
}, [rotation]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
<Ionicons name='sync' size={size} color={color} />
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
10
components/syncplay/index.ts
Normal file
10
components/syncplay/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* SyncPlay UI Components
|
||||
*/
|
||||
|
||||
export { GroupSelectionMenu } from "./GroupSelectionMenu";
|
||||
export { SyncPlayButton } from "./SyncPlayButton";
|
||||
export {
|
||||
SyncPlayIndicator,
|
||||
useSyncPlayIndicatorState,
|
||||
} from "./SyncPlayIndicator";
|
||||
@@ -63,6 +63,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const progress = useSharedValue(0);
|
||||
const cancelled = useSharedValue(false);
|
||||
const onFinishRef = useRef(onFinish);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
@@ -120,13 +121,15 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
cancelled.value = false;
|
||||
|
||||
// Resume from current position
|
||||
const remainingDuration = (1 - progress.value) * 8000;
|
||||
progress.value = withTiming(
|
||||
1,
|
||||
{ duration: remainingDuration, easing: Easing.linear },
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
if (finished && !cancelled.value) {
|
||||
runOnJS(onFinishRef.current)();
|
||||
}
|
||||
},
|
||||
@@ -134,9 +137,10 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
|
||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||
return () => {
|
||||
cancelled.value = true;
|
||||
cancelAnimation(progress);
|
||||
};
|
||||
}, [show, isPlaying, progress]);
|
||||
}, [show, isPlaying, progress, cancelled]);
|
||||
|
||||
const progressStyle = useAnimatedStyle(() => ({
|
||||
width: `${progress.value * 100}%`,
|
||||
|
||||
@@ -263,7 +263,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
color: "#fff",
|
||||
},
|
||||
downloadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: scaleSize(14),
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
|
||||
maximumValue={max}
|
||||
thumbWidth={0}
|
||||
onValueChange={handleValueChange}
|
||||
renderBubble={() => null}
|
||||
renderThumb={() => null}
|
||||
containerStyle={{
|
||||
borderRadius: 50,
|
||||
}}
|
||||
theme={{
|
||||
minimumTrackTintColor: "#FDFDFD",
|
||||
maximumTrackTintColor: "#5A5A5A",
|
||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||
bubbleTextColor: "transparent", // Hide the value text
|
||||
}}
|
||||
/>
|
||||
<Ionicons
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
ChapterInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Slider } from "react-native-awesome-slider";
|
||||
import { type SharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChapterList } from "@/components/chapters/ChapterList";
|
||||
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ChapterMarkers } from "./ChapterMarkers";
|
||||
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||
import SkipButton from "./SkipButton";
|
||||
import { TimeDisplay } from "./TimeDisplay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
|
||||
// Chapter tick height in dp — matches the slider track height for a clean,
|
||||
// flush look (no top/bottom overflow).
|
||||
const TICK_HEIGHT = 10;
|
||||
|
||||
interface BottomControlsProps {
|
||||
item: BaseItemDto;
|
||||
/** Item chapters, used for the tick overlay and chapter list. */
|
||||
chapters?: ChapterInfo[] | null;
|
||||
/** Total media duration in milliseconds. */
|
||||
durationMs: number;
|
||||
showControls: boolean;
|
||||
isSliding: boolean;
|
||||
showRemoteBubble: boolean;
|
||||
@@ -39,6 +54,8 @@ interface BottomControlsProps {
|
||||
handleSliderChange: (value: number) => void;
|
||||
handleTouchStart: () => void;
|
||||
handleTouchEnd: () => void;
|
||||
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
|
||||
seekTo: (value: number) => void;
|
||||
|
||||
// Trickplay props
|
||||
trickPlayUrl: {
|
||||
@@ -65,6 +82,8 @@ interface BottomControlsProps {
|
||||
|
||||
export const BottomControls: FC<BottomControlsProps> = ({
|
||||
item,
|
||||
chapters,
|
||||
durationMs,
|
||||
showControls,
|
||||
isSliding,
|
||||
showRemoteBubble,
|
||||
@@ -88,13 +107,39 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
handleSliderChange,
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
seekTo,
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
chapterPositions = [],
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
|
||||
// Only expose chapter UI when there are at least two real markers.
|
||||
const chapterMarkerList = useMemo(
|
||||
() => chapterMarkers(chapters, durationMs),
|
||||
[chapters, durationMs],
|
||||
);
|
||||
const hasChapters = chapterMarkerList.length > 1;
|
||||
|
||||
// Current chapter name for the always-visible header label (live playback).
|
||||
const currentChapterName = useMemo(
|
||||
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
|
||||
[hasChapters, currentTime, chapters],
|
||||
);
|
||||
|
||||
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
|
||||
// an {h,m,s} object derived from the slider's dragged value — convert back
|
||||
// to ms for the lookup. Only useful while actively scrubbing.
|
||||
const scrubChapterName = useMemo(() => {
|
||||
if (!hasChapters) return null;
|
||||
const scrubMs =
|
||||
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
|
||||
return chapterNameAt(scrubMs, chapters);
|
||||
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -136,8 +181,24 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
{item?.Type === "Audio" && (
|
||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
||||
)}
|
||||
{currentChapterName ? (
|
||||
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
|
||||
{currentChapterName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className='flex flex-row space-x-2 shrink-0'>
|
||||
<View className='flex flex-row items-center space-x-2 shrink-0'>
|
||||
{hasChapters && (
|
||||
<Pressable
|
||||
onPress={() => setChapterListVisible(true)}
|
||||
hitSlop={10}
|
||||
className='justify-center mr-4'
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={t("chapters.open")}
|
||||
>
|
||||
<Ionicons name='bookmarks' size={24} color='white' />
|
||||
</Pressable>
|
||||
)}
|
||||
<SkipButton
|
||||
showButton={showSkipButton}
|
||||
onPress={skipIntro}
|
||||
@@ -181,7 +242,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
height: 10,
|
||||
justifyContent: "center",
|
||||
alignItems: "stretch",
|
||||
position: "relative",
|
||||
// Allow chapter ticks taller than the 10px track to bleed out
|
||||
// top/bottom (RN defaults to overflow: "hidden" on Android).
|
||||
overflow: "visible",
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -209,6 +272,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={time}
|
||||
chapterName={scrubChapterName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -218,7 +282,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
minimumValue={min}
|
||||
maximumValue={max}
|
||||
/>
|
||||
<ChapterMarkers chapterPositions={chapterPositions} />
|
||||
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
|
||||
</View>
|
||||
<TimeDisplay
|
||||
currentTime={currentTime}
|
||||
@@ -226,6 +290,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<ChapterList
|
||||
visible={chapterListVisible}
|
||||
chapters={chapters}
|
||||
currentPositionMs={currentTime}
|
||||
onSeek={seekTo}
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
|
||||
maximumValue={max}
|
||||
thumbWidth={0}
|
||||
onValueChange={handleValueChange}
|
||||
renderBubble={() => null}
|
||||
renderThumb={() => null}
|
||||
containerStyle={{
|
||||
borderRadius: 50,
|
||||
}}
|
||||
theme={{
|
||||
minimumTrackTintColor: "#FDFDFD",
|
||||
maximumTrackTintColor: "#5A5A5A",
|
||||
bubbleBackgroundColor: "transparent", // Hide the value bubble
|
||||
bubbleTextColor: "transparent", // Hide the value text
|
||||
}}
|
||||
/>
|
||||
<Ionicons
|
||||
|
||||
@@ -4,11 +4,16 @@ import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { SyncPlaySpinner } from "@/components/syncplay/SyncPlaySpinner";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import AudioSlider from "./AudioSlider";
|
||||
import BrightnessSlider from "./BrightnessSlider";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
|
||||
// SyncPlay cyan color (matches Jellyfin-web)
|
||||
const SYNC_PLAY_COLOR = "#00a4dc";
|
||||
|
||||
interface CenterControlsProps {
|
||||
showControls: boolean;
|
||||
isPlaying: boolean;
|
||||
@@ -44,6 +49,18 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// SyncPlay state from global provider
|
||||
const {
|
||||
isEnabled: isSyncPlayEnabled,
|
||||
groupInfo,
|
||||
pendingPlaybackCommand,
|
||||
} = useSyncPlay();
|
||||
const isSyncPlayWaiting = isSyncPlayEnabled && groupInfo?.State === "Waiting";
|
||||
// Show the rotating SyncPlay icon ("schedule-play" in jellyfin-web) while a
|
||||
// play/pause request is in flight to the server.
|
||||
const isSyncPlayScheduling =
|
||||
isSyncPlayEnabled && pendingPlaybackCommand !== null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -121,7 +138,17 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
|
||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
{!isBuffering ? (
|
||||
{isSyncPlayScheduling ? (
|
||||
// SyncPlay command in flight - rotating spinner ("schedule-play")
|
||||
<SyncPlaySpinner size={ICON_SIZES.CENTER} />
|
||||
) : isSyncPlayWaiting ? (
|
||||
// SyncPlay waiting indicator - clock icon, still pressable to toggle
|
||||
<Ionicons
|
||||
name='time'
|
||||
size={ICON_SIZES.CENTER}
|
||||
color={SYNC_PLAY_COLOR}
|
||||
/>
|
||||
) : !isBuffering ? (
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={ICON_SIZES.CENTER}
|
||||
|
||||
@@ -267,6 +267,7 @@ export const Controls: FC<Props> = ({
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
seekTo,
|
||||
} = useVideoSlider({
|
||||
progress,
|
||||
isSeeking,
|
||||
@@ -555,6 +556,8 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<BottomControls
|
||||
item={item}
|
||||
chapters={item.Chapters}
|
||||
durationMs={maxMs}
|
||||
showControls={showControls}
|
||||
isSliding={isSliding}
|
||||
showRemoteBubble={showRemoteBubble}
|
||||
@@ -578,6 +581,7 @@ export const Controls: FC<Props> = ({
|
||||
handleSliderChange={handleSliderChange}
|
||||
handleTouchStart={handleTouchStart}
|
||||
handleTouchEnd={handleTouchEnd}
|
||||
seekTo={seekTo}
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||
import { useVideoTime } from "./hooks/useVideoTime";
|
||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||
import { TrickplayBubble } from "./TrickplayBubble";
|
||||
import type { Track } from "./types";
|
||||
import { useControlsTimeout } from "./useControlsTimeout";
|
||||
|
||||
interface Props {
|
||||
@@ -232,14 +233,8 @@ export const Controls: FC<Props> = ({
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const {
|
||||
bitrateValue,
|
||||
subtitleIndex: paramSubtitleIndex,
|
||||
audioIndex: paramAudioIndex,
|
||||
} = useLocalSearchParams<{
|
||||
const { bitrateValue } = useLocalSearchParams<{
|
||||
bitrateValue: string;
|
||||
subtitleIndex: string;
|
||||
audioIndex: string;
|
||||
}>();
|
||||
|
||||
const { nextItem: internalNextItem } = usePlaybackManager({
|
||||
@@ -315,6 +310,31 @@ export const Controls: FC<Props> = ({
|
||||
[onSubtitleIndexChange],
|
||||
);
|
||||
|
||||
// Re-fetch subtitle streams from the server (e.g. after a server-side
|
||||
// download) and map them to the modal's Track shape. setTrack drives the
|
||||
// player through the same handler used for manual subtitle selection.
|
||||
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
|
||||
try {
|
||||
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
|
||||
// Skip streams without a real index: `?? -1` would alias them to the
|
||||
// "disable subtitles" sentinel and mis-route selection.
|
||||
return streams
|
||||
.filter((stream) => typeof stream.Index === "number")
|
||||
.map((stream) => {
|
||||
const index = stream.Index as number;
|
||||
return {
|
||||
name:
|
||||
stream.DisplayTitle ||
|
||||
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||
index,
|
||||
setTrack: () => onSubtitleIndexChange?.(index),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [onRefreshSubtitleTracks, onSubtitleIndexChange]);
|
||||
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
@@ -491,6 +511,8 @@ export const Controls: FC<Props> = ({
|
||||
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
|
||||
() => {},
|
||||
);
|
||||
const exitingRef = useRef(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
const updateSeekBubbleTime = useCallback((ms: number) => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
@@ -555,10 +577,22 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const handleOpenSubtitleSheet = useCallback(() => {
|
||||
setLastOpenedModal("subtitle");
|
||||
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option
|
||||
const tracksWithoutDisable = (videoContextSubtitleTracks ?? []).filter(
|
||||
(track) => track.index !== -1,
|
||||
);
|
||||
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option.
|
||||
// Wrap each setTrack so selecting a subtitle ALSO updates the player's live
|
||||
// index via onSubtitleIndexChange. The modal is a separate route, so the
|
||||
// VideoContext router.setParams inside setTrack targets the modal — not the
|
||||
// player — leaving currentSubtitleIndex stale. Without this sync, the next
|
||||
// episode carries the previously-shown subtitle instead of the one the user
|
||||
// just picked. (The audio sheet already uses onAudioIndexChange directly.)
|
||||
const tracksWithoutDisable = (videoContextSubtitleTracks ?? [])
|
||||
.filter((track) => track.index !== -1)
|
||||
.map((track) => ({
|
||||
...track,
|
||||
setTrack: () => {
|
||||
track.setTrack();
|
||||
onSubtitleIndexChange?.(track.index);
|
||||
},
|
||||
}));
|
||||
showSubtitleModal({
|
||||
item,
|
||||
mediaSourceId: mediaSource?.Id,
|
||||
@@ -570,8 +604,12 @@ export const Controls: FC<Props> = ({
|
||||
(t) => t.index === -1,
|
||||
);
|
||||
disableTrack?.setTrack();
|
||||
onSubtitleIndexChange?.(-1);
|
||||
},
|
||||
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks: onRefreshSubtitleTracks
|
||||
? refreshSubtitleTracks
|
||||
: undefined,
|
||||
});
|
||||
controlsInteractionRef.current();
|
||||
}, [
|
||||
@@ -580,7 +618,10 @@ export const Controls: FC<Props> = ({
|
||||
mediaSource?.Id,
|
||||
videoContextSubtitleTracks,
|
||||
subtitleIndex,
|
||||
onSubtitleIndexChange,
|
||||
handleLocalSubtitleDownloaded,
|
||||
onRefreshSubtitleTracks,
|
||||
refreshSubtitleTracks,
|
||||
]);
|
||||
|
||||
const handleToggleTechnicalInfo = useCallback(() => {
|
||||
@@ -929,6 +970,16 @@ export const Controls: FC<Props> = ({
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
const handleWillExit = useCallback(() => {
|
||||
exitingRef.current = true;
|
||||
setIsExiting(true);
|
||||
}, []);
|
||||
|
||||
const handleCancelExit = useCallback(() => {
|
||||
exitingRef.current = false;
|
||||
setIsExiting(false);
|
||||
}, []);
|
||||
|
||||
const { isSliding: isRemoteSliding } = useRemoteControl({
|
||||
showControls: showControls,
|
||||
toggleControls,
|
||||
@@ -945,6 +996,8 @@ export const Controls: FC<Props> = ({
|
||||
onVerticalDpad: handleVerticalDpad,
|
||||
onHideControls: hideControls,
|
||||
onBack: handleBack,
|
||||
onWillExit: handleWillExit,
|
||||
onCancelExit: handleCancelExit,
|
||||
videoTitle: item?.Name ?? undefined,
|
||||
});
|
||||
|
||||
@@ -986,13 +1039,12 @@ export const Controls: FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the live selection passed down from the player (currentSubtitleIndex
|
||||
// / currentAudioIndex), not the stale URL params the episode started with.
|
||||
// This path runs on autoplay; the manual "Next" button uses goToNextItemProp.
|
||||
const previousIndexes = {
|
||||
subtitleIndex: paramSubtitleIndex
|
||||
? Number.parseInt(paramSubtitleIndex, 10)
|
||||
: undefined,
|
||||
audioIndex: paramAudioIndex
|
||||
? Number.parseInt(paramAudioIndex, 10)
|
||||
: undefined,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -1019,8 +1071,8 @@ export const Controls: FC<Props> = ({
|
||||
[
|
||||
nextItem,
|
||||
settings,
|
||||
paramSubtitleIndex,
|
||||
paramAudioIndex,
|
||||
subtitleIndex,
|
||||
audioIndex,
|
||||
mediaSource,
|
||||
bitrateValue,
|
||||
router,
|
||||
@@ -1030,6 +1082,7 @@ export const Controls: FC<Props> = ({
|
||||
goToNextItemRef.current = goToNextItem;
|
||||
|
||||
const handleAutoPlayFinish = useCallback(() => {
|
||||
if (exitingRef.current) return;
|
||||
goToNextItem({ isAutoPlay: true });
|
||||
}, [goToNextItem]);
|
||||
|
||||
@@ -1104,7 +1157,7 @@ export const Controls: FC<Props> = ({
|
||||
nextItem={nextItem}
|
||||
api={api}
|
||||
show={isCountdownActive}
|
||||
isPlaying={isPlaying}
|
||||
isPlaying={isPlaying && !isExiting}
|
||||
onFinish={handleAutoPlayFinish}
|
||||
onPlayNext={handleNextItemButton}
|
||||
controlsVisible={showControls}
|
||||
@@ -1408,14 +1461,14 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
controlsContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
},
|
||||
darkOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
focusStealingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...StyleSheet.absoluteFill,
|
||||
zIndex: 1,
|
||||
},
|
||||
bottomContainer: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
@@ -16,6 +17,8 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
currentTime,
|
||||
remainingTime,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
// remainingTime is in ms
|
||||
@@ -37,7 +40,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text className='text-[10px] text-neutral-500 opacity-70'>
|
||||
ends at {getFinishTime()}
|
||||
{t("player.ends_at", { time: getFinishTime() })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
|
||||
const BASE_IMAGE_SCALE = 1.4;
|
||||
// Slightly larger preview (scale 1.6 vs old 1.4) to give the overlay text
|
||||
// more room and feel closer to the Jellyfin web style.
|
||||
const BASE_IMAGE_SCALE = 1.6;
|
||||
const BUBBLE_LEFT_OFFSET = 62;
|
||||
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
|
||||
|
||||
@@ -28,12 +30,8 @@ interface TrickplayBubbleProps {
|
||||
};
|
||||
/** Scale factor for the image (default 1). Does not affect timestamp text. */
|
||||
imageScale?: number;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number, seconds: number): string {
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
const prefix = hours > 0 ? `${hours}:` : "";
|
||||
return `${prefix}${pad(minutes)}:${pad(seconds)}`;
|
||||
/** Chapter name at the scrubbed position, if any. */
|
||||
chapterName?: string | null;
|
||||
}
|
||||
|
||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
@@ -41,6 +39,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
trickplayInfo,
|
||||
time,
|
||||
imageScale = 1,
|
||||
chapterName,
|
||||
}) => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
@@ -49,19 +48,28 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
|
||||
|
||||
const finalScale = BASE_IMAGE_SCALE * imageScale;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
// Sit just above the slider — high enough not to overlap the
|
||||
// progress bar, low enough to feel anchored to the thumb.
|
||||
left: -BUBBLE_LEFT_OFFSET * imageScale,
|
||||
bottom: 0,
|
||||
paddingTop: 30,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 5,
|
||||
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
// Bring the bubble in front of the player title / overlays.
|
||||
zIndex: 999,
|
||||
elevation: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -89,10 +97,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
{/*
|
||||
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
|
||||
* faded) above the timestamp (small, bold). Sits on top of the
|
||||
* trickplay frame inside the same overflow:hidden container so it
|
||||
* always stays within the bubble bounds.
|
||||
*/}
|
||||
<View
|
||||
pointerEvents='none'
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
bottom: 3,
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 3,
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
maxWidth: tileWidth - 8,
|
||||
}}
|
||||
>
|
||||
{chapterName ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 7,
|
||||
opacity: 0.85,
|
||||
lineHeight: 9,
|
||||
}}
|
||||
>
|
||||
{chapterName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 8,
|
||||
fontWeight: "600",
|
||||
lineHeight: 10,
|
||||
}}
|
||||
>
|
||||
{timeStr}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={{ marginTop: 30, fontSize: 16 }}>
|
||||
{formatTime(time.hours, time.minutes, time.seconds)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,10 @@ interface UseRemoteControlProps {
|
||||
onLongSeekStop?: () => void;
|
||||
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
|
||||
onVerticalDpad?: () => void;
|
||||
/** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */
|
||||
onWillExit?: () => void;
|
||||
/** Called when the user cancels the exit confirmation Alert */
|
||||
onCancelExit?: () => void;
|
||||
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
|
||||
// These are ignored in the simplified implementation
|
||||
progress?: SharedValue<number>;
|
||||
@@ -72,6 +76,8 @@ export function useRemoteControl({
|
||||
onLongSeekRightStart,
|
||||
onLongSeekStop,
|
||||
onVerticalDpad,
|
||||
onWillExit,
|
||||
onCancelExit,
|
||||
}: UseRemoteControlProps) {
|
||||
// Keep these for backward compatibility with the component
|
||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||
@@ -85,13 +91,24 @@ export function useRemoteControl({
|
||||
const onHideControlsRef = useRef(onHideControls);
|
||||
const onBackRef = useRef(onBack);
|
||||
const videoTitleRef = useRef(videoTitle);
|
||||
const onWillExitRef = useRef(onWillExit);
|
||||
const onCancelExitRef = useRef(onCancelExit);
|
||||
|
||||
useEffect(() => {
|
||||
showControlsRef.current = showControls;
|
||||
onHideControlsRef.current = onHideControls;
|
||||
onBackRef.current = onBack;
|
||||
videoTitleRef.current = videoTitle;
|
||||
}, [showControls, onHideControls, onBack, videoTitle]);
|
||||
onWillExitRef.current = onWillExit;
|
||||
onCancelExitRef.current = onCancelExit;
|
||||
}, [
|
||||
showControls,
|
||||
onHideControls,
|
||||
onBack,
|
||||
videoTitle,
|
||||
onWillExit,
|
||||
onCancelExit,
|
||||
]);
|
||||
|
||||
// BackHandler owns player exit: Android TV sends hardware back here, and
|
||||
// react-native-tvos maps the Apple TV menu button to the same API.
|
||||
@@ -102,6 +119,9 @@ export function useRemoteControl({
|
||||
return true;
|
||||
}
|
||||
if (onBackRef.current) {
|
||||
// Signal Controls that exit is imminent (pauses countdown, sets guard)
|
||||
onWillExitRef.current?.();
|
||||
|
||||
// Controls are hidden, so confirm before leaving playback.
|
||||
Alert.alert(
|
||||
"Stop Playback",
|
||||
@@ -109,7 +129,11 @@ export function useRemoteControl({
|
||||
? `Stop playing "${videoTitleRef.current}"?`
|
||||
: "Are you sure you want to stop playback?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Cancel",
|
||||
style: "cancel",
|
||||
onPress: () => onCancelExitRef.current?.(),
|
||||
},
|
||||
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||
],
|
||||
);
|
||||
|
||||
@@ -74,6 +74,21 @@ export function useVideoSlider({
|
||||
[seek, play, progress, isSeeking],
|
||||
);
|
||||
|
||||
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
|
||||
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
|
||||
// during a real slide and would carry stale state on a tap-to-seek.
|
||||
const seekTo = useCallback(
|
||||
(value: number) => {
|
||||
const seekValue = Math.max(0, Math.floor(value));
|
||||
progress.value = seekValue;
|
||||
seek(seekValue);
|
||||
if (isPlaying) {
|
||||
play();
|
||||
}
|
||||
},
|
||||
[seek, play, progress, isPlaying],
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
debounce((value: number) => {
|
||||
// Convert ms to ticks for trickplay
|
||||
@@ -96,5 +111,6 @@ export function useVideoSlider({
|
||||
handleTouchEnd,
|
||||
handleSliderComplete,
|
||||
handleSliderChange,
|
||||
seekTo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,4 +28,4 @@ type Track = {
|
||||
localPath?: string;
|
||||
};
|
||||
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
||||
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||
|
||||
@@ -49,8 +49,8 @@ export const TVGaps = {
|
||||
* Base padding values in pixels.
|
||||
*/
|
||||
export const TVPadding = {
|
||||
/** Horizontal padding from screen edges */
|
||||
horizontal: 90,
|
||||
/** Horizontal padding from screen edges (static — matches native search inset) */
|
||||
horizontal: 80,
|
||||
|
||||
/** Padding to accommodate scale animations (1.05x) */
|
||||
scale: 20,
|
||||
@@ -142,7 +142,9 @@ export const useScaledTVSizes = (): ScaledTVSizes => {
|
||||
large: Math.round(scaleSize(TVGaps.large) * scale),
|
||||
},
|
||||
padding: {
|
||||
horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale),
|
||||
// Static: matches the native tvOS search bar inset, which is a fixed
|
||||
// point value and does not change with the typography scale setting.
|
||||
horizontal: TVPadding.horizontal,
|
||||
scale: Math.round(scaleSize(TVPadding.scale) * scale),
|
||||
vertical: Math.round(scaleSize(TVPadding.vertical) * scale),
|
||||
heroHeight: TVPadding.heroHeight * scale,
|
||||
|
||||
25
eas.json
25
eas.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 9.1.0"
|
||||
"version": ">= 9.1.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@@ -52,14 +53,14 @@
|
||||
},
|
||||
"production": {
|
||||
"environment": "production",
|
||||
"channel": "0.54.0",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"environment": "production",
|
||||
"channel": "0.54.0",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -67,7 +68,7 @@
|
||||
},
|
||||
"production-apk-tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.54.0",
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
@@ -78,7 +79,7 @@
|
||||
},
|
||||
"production_tv": {
|
||||
"environment": "production",
|
||||
"channel": "0.54.0",
|
||||
"autoIncrement": true,
|
||||
"env": {
|
||||
"EXPO_TV": "1"
|
||||
},
|
||||
@@ -88,7 +89,17 @@
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {},
|
||||
"production_tv": {}
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleTeamId": "MWD5K362T8",
|
||||
"ascAppId": "6593660679"
|
||||
}
|
||||
},
|
||||
"production_tv": {
|
||||
"ios": {
|
||||
"appleTeamId": "MWD5K362T8",
|
||||
"ascAppId": "6593660679"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ExtraQueryOptions = {
|
||||
gcTime?: number;
|
||||
staleTime?: number;
|
||||
};
|
||||
|
||||
export const useItemQuery = (
|
||||
itemId: string | undefined,
|
||||
isOffline?: boolean,
|
||||
fields?: ItemFields[],
|
||||
excludeFields?: ItemFields[],
|
||||
queryOptions?: ExtraQueryOptions,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -56,5 +62,6 @@ export const useItemQuery = (
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
networkMode: "always",
|
||||
...queryOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ export const usePlaybackManager = ({
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!item || !item.SeriesId) {
|
||||
if (!item?.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
50
hooks/useRefreshLibraryOnFocus.ts
Normal file
50
hooks/useRefreshLibraryOnFocus.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
|
||||
// Query keys that depend on the set of library items. Kept in sync with the
|
||||
// LibraryChanged handler in WebSocketProvider.
|
||||
const LIBRARY_QUERY_KEYS = [
|
||||
["home"],
|
||||
["library-items"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
["resumeItems"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Fallback refresh for newly added/removed content.
|
||||
*
|
||||
* The primary path is the server's `LibraryChanged` WebSocket event (handled in
|
||||
* WebSocketProvider). This hook is a safety net for cases where the socket was
|
||||
* down or the change happened while the screen was unfocused: when the screen
|
||||
* regains focus, it invalidates the library-dependent queries so React Query
|
||||
* refetches the latest content.
|
||||
*
|
||||
* Skips the refresh on the very first focus (initial mount already fetches) and
|
||||
* throttles to avoid refetch storms when quickly switching tabs.
|
||||
*/
|
||||
export function useRefreshLibraryOnFocus(throttleMs = 30_000) {
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const hasFocusedOnce = useRef(false);
|
||||
const lastRefreshRef = useRef(0);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!hasFocusedOnce.current) {
|
||||
hasFocusedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshRef.current < throttleMs) {
|
||||
return;
|
||||
}
|
||||
lastRefreshRef.current = now;
|
||||
|
||||
for (const queryKey of LIBRARY_QUERY_KEYS) {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
}, [queryClient, throttleMs]),
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export const useSessions = ({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
if (!api || !user?.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
@@ -55,7 +55,7 @@ export const useAllSessions = ({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["allSessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
if (!api || !user?.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
|
||||
@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSyncPlay } from "@/providers/SyncPlay/SyncPlayProvider";
|
||||
import { useWebSocketContext } from "@/providers/WebSocketProvider";
|
||||
|
||||
interface UseWebSocketProps {
|
||||
@@ -80,9 +81,9 @@ export const useWebSocket = ({
|
||||
playTrailers,
|
||||
}: UseWebSocketProps) => {
|
||||
const router = useRouter();
|
||||
const { lastMessage } = useWebSocketContext();
|
||||
const { lastMessage, clearLastMessage } = useWebSocketContext();
|
||||
const { t } = useTranslation();
|
||||
const { clearLastMessage } = useWebSocketContext();
|
||||
const { isEnabled: isSyncPlayEnabled } = useSyncPlay();
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage) return;
|
||||
@@ -96,6 +97,25 @@ export const useWebSocket = ({
|
||||
| Record<string, string>
|
||||
| undefined; // Arguments are Dictionary<string, string>
|
||||
|
||||
// Skip playback commands when SyncPlay is enabled - SyncPlay handles these
|
||||
const isSyncPlayCommand =
|
||||
lastMessage.MessageType === "SyncPlayCommand" ||
|
||||
lastMessage.MessageType === "SyncPlayGroupUpdate";
|
||||
const isPlaybackCommand = [
|
||||
"PlayPause",
|
||||
"Pause",
|
||||
"Unpause",
|
||||
"Stop",
|
||||
"Seek",
|
||||
"NextTrack",
|
||||
"PreviousTrack",
|
||||
].includes(command ?? "");
|
||||
|
||||
if (isSyncPlayEnabled && (isSyncPlayCommand || isPlaybackCommand)) {
|
||||
console.log(`Command ~ ${command} - skipping, SyncPlay handles playback`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "PlayPause") {
|
||||
console.log("Command ~ PlayPause");
|
||||
togglePlay();
|
||||
|
||||
@@ -236,37 +236,43 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach surface and re-enable video output.
|
||||
* Based on Findroid's implementation.
|
||||
* Attach surface and ensure video output is active.
|
||||
*
|
||||
* During PiP transitions, the surface is destroyed and recreated by Android.
|
||||
* We keep the VO pipeline alive (not killed with vo=null) so that rendering
|
||||
* resumes immediately when the new surface is attached — avoiding the black
|
||||
* screen that occurs when the VO is fully re-initialized via setOptionString.
|
||||
*/
|
||||
fun attachSurface(surface: Surface) {
|
||||
this.surface = surface
|
||||
Log.i(TAG, "[PiP] attachSurface — isRunning=$isRunning, vo=$voDriver, surface=${surface.hashCode()}")
|
||||
if (isRunning) {
|
||||
MPVLib.attachSurface(surface)
|
||||
// Re-enable video output after attaching surface (Findroid approach)
|
||||
MPVLib.setOptionString("force-window", "yes")
|
||||
MPVLib.setOptionString("vo", voDriver)
|
||||
Log.i(TAG, "Surface attached, video output re-enabled (vo=$voDriver)")
|
||||
// Read back vo to confirm it's still active
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] attachSurface — attached, activeVo=$activeVo")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Detach surface and disable video output.
|
||||
* Based on Findroid's implementation.
|
||||
* Detach surface without killing the VO pipeline.
|
||||
*
|
||||
* The previous approach (vo=null / force-window=no) destroyed the entire video
|
||||
* output pipeline on every surface transition. During PiP mode, the rapid
|
||||
* destroy/recreate cycle caused a black screen because setOptionString("vo", ...)
|
||||
* did not properly re-initialize rendering into the new PiP surface.
|
||||
*
|
||||
* By keeping the VO alive, frames are simply dropped while no surface is
|
||||
* attached, and rendering resumes immediately when the new surface arrives.
|
||||
*/
|
||||
fun detachSurface() {
|
||||
this.surface = null
|
||||
Log.i(TAG, "[PiP] detachSurface — isRunning=$isRunning, vo=$voDriver")
|
||||
if (isRunning) {
|
||||
try {
|
||||
// Disable video output before detaching surface (Findroid approach)
|
||||
MPVLib.setOptionString("vo", "null")
|
||||
MPVLib.setOptionString("force-window", "no")
|
||||
Log.i(TAG, "Video output disabled before surface detach")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to disable video output: ${e.message}")
|
||||
}
|
||||
|
||||
MPVLib.detachSurface()
|
||||
val activeVo = try { MPVLib.getPropertyString("vo") } catch (e: Exception) { null }
|
||||
Log.i(TAG, "[PiP] detachSurface — detached, activeVo=$activeVo (should still be $voDriver)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +283,24 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
fun updateSurfaceSize(width: Int, height: Int) {
|
||||
if (isRunning) {
|
||||
MPVLib.setPropertyString("android-surface-size", "${width}x$height")
|
||||
Log.i(TAG, "Surface size updated: ${width}x$height")
|
||||
Log.i(TAG, "[PiP] updateSurfaceSize — ${width}x${height}")
|
||||
} else {
|
||||
Log.w(TAG, "[PiP] updateSurfaceSize — called but renderer not running")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force mpv to render a frame to the current surface.
|
||||
* Steps forward one frame then seeks back to the original position.
|
||||
* Used after PiP entry to work around mpv stopping pixel output.
|
||||
*/
|
||||
fun forceRedraw() {
|
||||
if (!isRunning) return
|
||||
val pos = cachedPosition
|
||||
Log.i(TAG, "[PiP] forceRedraw — stepping frame then seeking to $pos")
|
||||
MPVLib.command(arrayOf("frame-step"))
|
||||
if (pos > 0) {
|
||||
MPVLib.command(arrayOf("seek", pos.toString(), "absolute"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,11 +707,17 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
|
||||
MPVLib.command(arrayOf("sub-add", subUrl, "auto"))
|
||||
}
|
||||
pendingExternalSubtitles = emptyList()
|
||||
|
||||
// Set subtitle after external subs are added
|
||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||
}
|
||||
|
||||
|
||||
// Apply the initial audio/subtitle selection now that the file's
|
||||
// tracks are enumerated. Setting sid/aid before `loadfile` does not
|
||||
// reliably stick for embedded tracks (the selection is silently
|
||||
// dropped), so we (re)apply here for embedded and external alike.
|
||||
// This is what makes a carried-over subtitle show up on the next
|
||||
// episode without a manual re-selection.
|
||||
initialAudioId?.let { if (it > 0) setAudioTrack(it) }
|
||||
initialSubtitleId?.let { setSubtitleTrack(it) } ?: disableSubtitles()
|
||||
|
||||
if (!isReadyToSeek) {
|
||||
isReadyToSeek = true
|
||||
mainHandler.post { delegate?.onReadyToSeek() }
|
||||
|
||||
@@ -198,7 +198,7 @@ class MpvPlayerModule : Module() {
|
||||
}
|
||||
|
||||
// Defines events that the view can send to JavaScript
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
|
||||
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package expo.modules.mpvplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.graphics.Rect
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import android.widget.FrameLayout
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import expo.modules.kotlin.AppContext
|
||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||
import expo.modules.kotlin.views.ExpoView
|
||||
@@ -28,26 +31,27 @@ data class VideoLoadConfig(
|
||||
|
||||
/**
|
||||
* MpvPlayerView - ExpoView that hosts the MPV player.
|
||||
* This mirrors the iOS MpvPlayerView implementation.
|
||||
* Uses TextureView for reliable Picture-in-Picture support.
|
||||
*/
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, SurfaceHolder.Callback {
|
||||
|
||||
class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext),
|
||||
MPVLayerRenderer.Delegate, TextureView.SurfaceTextureListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerView"
|
||||
}
|
||||
|
||||
|
||||
// Event dispatchers
|
||||
val onLoad by EventDispatcher()
|
||||
val onPlaybackStateChange by EventDispatcher()
|
||||
val onProgress by EventDispatcher()
|
||||
val onError by EventDispatcher()
|
||||
val onTracksReady by EventDispatcher()
|
||||
|
||||
private var surfaceView: SurfaceView
|
||||
val onPictureInPictureChange by EventDispatcher()
|
||||
|
||||
private var textureView: TextureView
|
||||
private var renderer: MPVLayerRenderer? = null
|
||||
private var pipController: PiPController? = null
|
||||
|
||||
|
||||
private var currentUrl: String? = null
|
||||
private var cachedPosition: Double = 0.0
|
||||
private var cachedDuration: Double = 0.0
|
||||
@@ -56,23 +60,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
private var pendingConfig: VideoLoadConfig? = null
|
||||
private var rendererStarted: Boolean = false
|
||||
private var pendingSurface: Surface? = null
|
||||
private var surfaceTexture: SurfaceTexture? = null
|
||||
|
||||
// PiP state tracking
|
||||
private var isWaitingForPiPTransition: Boolean = false
|
||||
private var isPiPSurfaceForced: Boolean = false
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
init {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
|
||||
// Create SurfaceView for video rendering
|
||||
surfaceView = SurfaceView(context).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
FrameLayout.LayoutParams.MATCH_PARENT
|
||||
// Create TextureView for video rendering (composites into app window for PiP support)
|
||||
textureView = TextureView(context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
holder.addCallback(this@MpvPlayerView)
|
||||
surfaceTextureListener = this@MpvPlayerView
|
||||
}
|
||||
addView(surfaceView)
|
||||
addView(textureView)
|
||||
|
||||
// Initialize PiP controller with Expo's AppContext for proper activity access
|
||||
pipController = PiPController(context, appContext)
|
||||
pipController?.setPlayerView(surfaceView)
|
||||
pipController?.setPlayerView(textureView)
|
||||
pipController?.delegate = object : PiPController.Delegate {
|
||||
override fun onPlay() {
|
||||
play()
|
||||
@@ -85,6 +95,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
override fun onSeekBy(seconds: Double) {
|
||||
seekBy(seconds)
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPiP: Boolean) {
|
||||
if (isInPiP) {
|
||||
if (!isWaitingForPiPTransition) {
|
||||
isWaitingForPiPTransition = true
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
restoreFromPiP()
|
||||
}
|
||||
onPictureInPictureChange(mapOf("isActive" to isInPiP))
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer is created lazily in loadVideo once we have the voDriver setting
|
||||
@@ -102,32 +129,29 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
try {
|
||||
renderer?.start(voDriver ?: "gpu-next")
|
||||
rendererStarted = true
|
||||
Log.i(TAG, "Renderer started with vo=$voDriver")
|
||||
|
||||
// If surface was created before renderer started, attach it now
|
||||
pendingSurface?.let { surface ->
|
||||
renderer?.attachSurface(surface)
|
||||
pendingSurface = null
|
||||
Log.i(TAG, "Attached pending surface after renderer start")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start renderer: ${e.message}")
|
||||
onError(mapOf("error" to "Failed to start renderer: ${e.message}"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SurfaceHolder.Callback
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
Log.i(TAG, "Surface created")
|
||||
|
||||
// MARK: - TextureView.SurfaceTextureListener
|
||||
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
this.surfaceTexture = surfaceTexture
|
||||
val surface = Surface(surfaceTexture)
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
surfaceReady = true
|
||||
|
||||
if (rendererStarted) {
|
||||
renderer?.attachSurface(holder.surface)
|
||||
renderer?.attachSurface(surface)
|
||||
} else {
|
||||
// Renderer not started yet - store surface to attach after start
|
||||
pendingSurface = holder.surface
|
||||
Log.i(TAG, "Surface created before renderer started, storing as pending")
|
||||
pendingSurface = surface
|
||||
}
|
||||
|
||||
// If we have a pending load, execute it now
|
||||
@@ -137,19 +161,23 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
pendingConfig = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
Log.i(TAG, "Surface changed: ${width}x${height}")
|
||||
// Update MPV with the new surface size (Findroid approach)
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
surfaceTexture.setDefaultBufferSize(width, height)
|
||||
renderer?.updateSurfaceSize(width, height)
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
Log.i(TAG, "Surface destroyed")
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
this.surfaceTexture = null
|
||||
surfaceReady = false
|
||||
renderer?.detachSurface()
|
||||
return false // mpv manages the SurfaceTexture
|
||||
}
|
||||
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
// Called every frame — no action needed, mpv drives rendering directly
|
||||
}
|
||||
|
||||
// MARK: - Video Loading
|
||||
|
||||
fun loadVideo(config: VideoLoadConfig) {
|
||||
@@ -169,10 +197,10 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
|
||||
loadVideoInternal(config)
|
||||
}
|
||||
|
||||
|
||||
private fun loadVideoInternal(config: VideoLoadConfig) {
|
||||
currentUrl = config.url
|
||||
|
||||
|
||||
renderer?.load(
|
||||
url = config.url,
|
||||
headers = config.headers,
|
||||
@@ -181,124 +209,173 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
initialSubtitleId = config.initialSubtitleId,
|
||||
initialAudioId = config.initialAudioId
|
||||
)
|
||||
|
||||
|
||||
if (config.autoplay) {
|
||||
play()
|
||||
}
|
||||
|
||||
|
||||
onLoad(mapOf("url" to config.url))
|
||||
}
|
||||
|
||||
|
||||
// Convenience method for simple loads
|
||||
fun loadVideo(url: String, headers: Map<String, String>? = null) {
|
||||
loadVideo(VideoLoadConfig(url = url, headers = headers))
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
|
||||
fun play() {
|
||||
intendedPlayState = true
|
||||
renderer?.play()
|
||||
pipController?.setPlaybackRate(1.0)
|
||||
}
|
||||
|
||||
|
||||
fun pause() {
|
||||
intendedPlayState = false
|
||||
renderer?.pause()
|
||||
pipController?.setPlaybackRate(0.0)
|
||||
}
|
||||
|
||||
|
||||
fun seekTo(position: Double) {
|
||||
renderer?.seekTo(position)
|
||||
}
|
||||
|
||||
|
||||
fun seekBy(offset: Double) {
|
||||
renderer?.seekBy(offset)
|
||||
}
|
||||
|
||||
|
||||
fun setSpeed(speed: Double) {
|
||||
renderer?.setSpeed(speed)
|
||||
}
|
||||
|
||||
|
||||
fun getSpeed(): Double {
|
||||
return renderer?.getSpeed() ?: 1.0
|
||||
}
|
||||
|
||||
|
||||
fun isPaused(): Boolean {
|
||||
return renderer?.isPausedState ?: true
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentPosition(): Double {
|
||||
return cachedPosition
|
||||
}
|
||||
|
||||
|
||||
fun getDuration(): Double {
|
||||
return cachedDuration
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
|
||||
fun startPictureInPicture() {
|
||||
Log.i(TAG, "startPictureInPicture called")
|
||||
isWaitingForPiPTransition = true
|
||||
pipController?.startPictureInPicture()
|
||||
|
||||
// Resize buffer to match PiP window after animation settles
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
for (delay in longArrayOf(500, 1000, 1500, 2000)) {
|
||||
pipHandler.postDelayed({ forcePiPBufferSize() }, delay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resize the SurfaceTexture buffer AND TextureView layout to match the PiP
|
||||
* visible rect so mpv renders at the PiP window's actual dimensions.
|
||||
*/
|
||||
private fun forcePiPBufferSize() {
|
||||
if (!isWaitingForPiPTransition || !surfaceReady) return
|
||||
|
||||
val rect = Rect()
|
||||
textureView.getGlobalVisibleRect(rect)
|
||||
val visW = rect.width()
|
||||
val visH = rect.height()
|
||||
val vw = textureView.width
|
||||
val vh = textureView.height
|
||||
|
||||
if (visW <= 0 || visH <= 0 || (vw == visW && vh == visH)) return
|
||||
|
||||
surfaceTexture?.setDefaultBufferSize(visW, visH)
|
||||
renderer?.updateSurfaceSize(visW, visH)
|
||||
|
||||
// Force TextureView layout to match PiP visible area.
|
||||
// layoutParams alone doesn't work during PiP because the parent
|
||||
// never re-lays out its children.
|
||||
textureView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(visW, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(visH, View.MeasureSpec.EXACTLY)
|
||||
)
|
||||
textureView.layout(0, 0, visW, visH)
|
||||
isPiPSurfaceForced = true
|
||||
}
|
||||
|
||||
private fun restoreFromPiP() {
|
||||
if (!isPiPSurfaceForced) return
|
||||
isPiPSurfaceForced = false
|
||||
|
||||
val lp = textureView.layoutParams
|
||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
lp.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
textureView.layoutParams = lp
|
||||
textureView.requestLayout()
|
||||
}
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
|
||||
fun isPictureInPictureSupported(): Boolean {
|
||||
return pipController?.isPictureInPictureSupported() ?: false
|
||||
}
|
||||
|
||||
|
||||
fun isPictureInPictureActive(): Boolean {
|
||||
return pipController?.isPictureInPictureActive() ?: false
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Subtitle Controls
|
||||
|
||||
|
||||
fun getSubtitleTracks(): List<Map<String, Any>> {
|
||||
return renderer?.getSubtitleTracks() ?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleTrack(trackId: Int) {
|
||||
renderer?.setSubtitleTrack(trackId)
|
||||
}
|
||||
|
||||
|
||||
fun disableSubtitles() {
|
||||
renderer?.disableSubtitles()
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentSubtitleTrack(): Int {
|
||||
return renderer?.getCurrentSubtitleTrack() ?: 0
|
||||
}
|
||||
|
||||
|
||||
fun addSubtitleFile(url: String, select: Boolean = true) {
|
||||
renderer?.addSubtitleFile(url, select)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Subtitle Positioning
|
||||
|
||||
|
||||
fun setSubtitlePosition(position: Int) {
|
||||
renderer?.setSubtitlePosition(position)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleScale(scale: Double) {
|
||||
renderer?.setSubtitleScale(scale)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleMarginY(margin: Int) {
|
||||
renderer?.setSubtitleMarginY(margin)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleAlignX(alignment: String) {
|
||||
renderer?.setSubtitleAlignX(alignment)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleAlignY(alignment: String) {
|
||||
renderer?.setSubtitleAlignY(alignment)
|
||||
}
|
||||
|
||||
|
||||
fun setSubtitleFontSize(size: Int) {
|
||||
renderer?.setSubtitleFontSize(size)
|
||||
}
|
||||
@@ -316,15 +393,15 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
// MARK: - Audio Track Controls
|
||||
|
||||
|
||||
fun getAudioTracks(): List<Map<String, Any>> {
|
||||
return renderer?.getAudioTracks() ?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
fun setAudioTrack(trackId: Int) {
|
||||
renderer?.setAudioTrack(trackId)
|
||||
}
|
||||
|
||||
|
||||
fun getCurrentAudioTrack(): Int {
|
||||
return renderer?.getCurrentAudioTrack() ?: 0
|
||||
}
|
||||
@@ -349,16 +426,16 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
}
|
||||
|
||||
// MARK: - MPVLayerRenderer.Delegate
|
||||
|
||||
|
||||
override fun onPositionChanged(position: Double, duration: Double, cacheSeconds: Double) {
|
||||
cachedPosition = position
|
||||
cachedDuration = duration
|
||||
|
||||
|
||||
// Update PiP progress
|
||||
if (pipController?.isPictureInPictureActive() == true) {
|
||||
pipController?.setCurrentTime(position, duration)
|
||||
}
|
||||
|
||||
|
||||
onProgress(mapOf(
|
||||
"position" to position,
|
||||
"duration" to duration,
|
||||
@@ -366,50 +443,51 @@ class MpvPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
||||
"cacheSeconds" to cacheSeconds
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onPauseChanged(isPaused: Boolean) {
|
||||
// Sync PiP playback rate
|
||||
pipController?.setPlaybackRate(if (isPaused) 0.0 else 1.0)
|
||||
|
||||
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isPaused" to isPaused,
|
||||
"isPlaying" to !isPaused
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onLoadingChanged(isLoading: Boolean) {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isLoading" to isLoading
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onReadyToSeek() {
|
||||
onPlaybackStateChange(mapOf(
|
||||
"isReadyToSeek" to true
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
override fun onTracksReady() {
|
||||
onTracksReady(emptyMap<String, Any>())
|
||||
}
|
||||
|
||||
|
||||
override fun onVideoDimensionsChanged(width: Int, height: Int) {
|
||||
// Update PiP controller with video dimensions for proper aspect ratio
|
||||
pipController?.setVideoDimensions(width, height)
|
||||
}
|
||||
|
||||
|
||||
override fun onError(message: String) {
|
||||
onError(mapOf("error" to message))
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
|
||||
fun cleanup() {
|
||||
isWaitingForPiPTransition = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipController?.stopPictureInPicture()
|
||||
renderer?.stop()
|
||||
surfaceView.holder.removeCallback(this)
|
||||
surfaceTexture = null
|
||||
surfaceReady = false
|
||||
}
|
||||
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
cleanup()
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
package expo.modules.mpvplayer
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import expo.modules.kotlin.AppContext
|
||||
|
||||
/**
|
||||
* Picture-in-Picture controller for Android.
|
||||
* This mirrors the iOS PiPController implementation.
|
||||
*/
|
||||
class PiPController(private val context: Context, private val appContext: AppContext? = null) {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PiPController"
|
||||
private const val DEFAULT_ASPECT_WIDTH = 16
|
||||
private const val DEFAULT_ASPECT_HEIGHT = 9
|
||||
private const val ACTION_PIP_PLAY_PAUSE = "expo.modules.mpvplayer.PIP_PLAY_PAUSE"
|
||||
private const val ACTION_PIP_SKIP_FORWARD = "expo.modules.mpvplayer.PIP_SKIP_FORWARD"
|
||||
private const val ACTION_PIP_SKIP_BACKWARD = "expo.modules.mpvplayer.PIP_SKIP_BACKWARD"
|
||||
}
|
||||
|
||||
|
||||
interface Delegate {
|
||||
fun onPlay()
|
||||
fun onPause()
|
||||
fun onSeekBy(seconds: Double)
|
||||
fun onPictureInPictureModeChanged(isInPiP: Boolean)
|
||||
}
|
||||
|
||||
|
||||
var delegate: Delegate? = null
|
||||
|
||||
|
||||
private var currentPosition: Double = 0.0
|
||||
private var currentDuration: Double = 0.0
|
||||
private var playbackRate: Double = 1.0
|
||||
|
||||
// Video dimensions for proper aspect ratio
|
||||
|
||||
private var videoWidth: Int = 0
|
||||
private var videoHeight: Int = 0
|
||||
|
||||
// Reference to the player view for source rect
|
||||
private var playerView: View? = null
|
||||
|
||||
/**
|
||||
* Check if Picture-in-Picture is supported on this device
|
||||
*/
|
||||
|
||||
// PiP state tracking
|
||||
private var isInPiPMode: Boolean = false
|
||||
private var pipEntryNotified: Boolean = false
|
||||
private val pipHandler = Handler(Looper.getMainLooper())
|
||||
private var lifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null
|
||||
private var lifecycleRegistered = false
|
||||
private var pipBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
fun isPictureInPictureSupported(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
@@ -53,10 +64,7 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Picture-in-Picture is currently active
|
||||
*/
|
||||
|
||||
fun isPictureInPictureActive(): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
@@ -64,69 +72,69 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Picture-in-Picture mode
|
||||
*/
|
||||
|
||||
fun startPictureInPicture() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity == null) {
|
||||
Log.e(TAG, "Cannot start PiP: no activity found")
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val activity = getActivity() ?: run {
|
||||
Log.e(TAG, "Cannot start PiP: no activity")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPictureInPictureSupported()) {
|
||||
Log.e(TAG, "PiP not supported on this device")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val params = buildPiPParams(forEntering = true)
|
||||
val result = activity.enterPictureInPictureMode(params)
|
||||
|
||||
if (!result) {
|
||||
Log.e(TAG, "enterPictureInPictureMode rejected by system")
|
||||
isInPiPMode = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPictureInPictureSupported()) {
|
||||
Log.e(TAG, "PiP not supported on this device")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val params = buildPiPParams(forEntering = true)
|
||||
activity.enterPictureInPictureMode(params)
|
||||
Log.i(TAG, "Entered PiP mode")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "PiP requires Android O or higher")
|
||||
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
registerLifecycleCallbacks()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to enter PiP: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Picture-in-Picture mode
|
||||
*/
|
||||
|
||||
fun stopPictureInPicture() {
|
||||
// On Android, exiting PiP is typically done by the user
|
||||
// or by finishing the activity. We can request to move task to back.
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
unregisterLifecycleCallbacks()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
// Move task to back which will exit PiP
|
||||
activity.moveTaskToBack(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current playback position and duration
|
||||
* Note: We don't update PiP params here as we're not using progress in PiP controls
|
||||
*/
|
||||
|
||||
fun isCurrentlyInPiP(): Boolean = isInPiPMode
|
||||
|
||||
fun setCurrentTime(position: Double, duration: Double) {
|
||||
currentPosition = position
|
||||
currentDuration = duration
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playback rate (0.0 for paused, 1.0 for playing)
|
||||
*/
|
||||
|
||||
fun setPlaybackRate(rate: Double) {
|
||||
playbackRate = rate
|
||||
|
||||
// Update PiP params to reflect play/pause state
|
||||
|
||||
if (rate > 0) {
|
||||
registerLifecycleCallbacks()
|
||||
}
|
||||
|
||||
// Update PiP params so autoEnterEnabled and action icons track play/pause state
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
if (activity != null) {
|
||||
try {
|
||||
activity.setPictureInPictureParams(buildPiPParams())
|
||||
} catch (e: Exception) {
|
||||
@@ -135,28 +143,19 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the video dimensions for proper aspect ratio calculation
|
||||
*/
|
||||
|
||||
fun setVideoDimensions(width: Int, height: Int) {
|
||||
if (width > 0 && height > 0) {
|
||||
videoWidth = width
|
||||
videoHeight = height
|
||||
Log.i(TAG, "Video dimensions set: ${width}x${height}")
|
||||
|
||||
// Update PiP params if active
|
||||
updatePiPParamsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the player view reference for source rect hint
|
||||
*/
|
||||
|
||||
fun setPlayerView(view: View?) {
|
||||
playerView = view
|
||||
}
|
||||
|
||||
|
||||
private fun updatePiPParamsIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val activity = getActivity()
|
||||
@@ -169,23 +168,16 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Picture-in-Picture params for the current player state.
|
||||
* Calculates proper aspect ratio and source rect based on video and view dimensions.
|
||||
*/
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPiPParams(forEntering: Boolean = false): PictureInPictureParams {
|
||||
val view = playerView
|
||||
val viewWidth = view?.width ?: 0
|
||||
val viewHeight = view?.height ?: 0
|
||||
|
||||
// Display aspect ratio from view (exactly like Findroid)
|
||||
|
||||
val displayAspectRatio = Rational(viewWidth.coerceAtLeast(1), viewHeight.coerceAtLeast(1))
|
||||
|
||||
// Video aspect ratio with 2.39:1 clamping (exactly like Findroid)
|
||||
// Findroid: Rational(it.width.coerceAtMost((it.height * 2.39f).toInt()),
|
||||
// it.height.coerceAtMost((it.width * 2.39f).toInt()))
|
||||
|
||||
// Video aspect ratio with 2.39:1 clamping
|
||||
val aspectRatio = if (videoWidth > 0 && videoHeight > 0) {
|
||||
Rational(
|
||||
videoWidth.coerceAtMost((videoHeight * 2.39f).toInt()),
|
||||
@@ -194,70 +186,235 @@ class PiPController(private val context: Context, private val appContext: AppCon
|
||||
} else {
|
||||
Rational(DEFAULT_ASPECT_WIDTH, DEFAULT_ASPECT_HEIGHT)
|
||||
}
|
||||
|
||||
// Source rect hint calculation (exactly like Findroid)
|
||||
|
||||
val sourceRectHint = if (viewWidth > 0 && viewHeight > 0 && videoWidth > 0 && videoHeight > 0) {
|
||||
if (displayAspectRatio < aspectRatio) {
|
||||
// Letterboxing - black bars top/bottom
|
||||
val space = ((viewHeight - (viewWidth.toFloat() / aspectRatio.toFloat())) / 2).toInt()
|
||||
Rect(
|
||||
0,
|
||||
space,
|
||||
viewWidth,
|
||||
(viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space
|
||||
)
|
||||
Rect(0, space, viewWidth, (viewWidth.toFloat() / aspectRatio.toFloat()).toInt() + space)
|
||||
} else {
|
||||
// Pillarboxing - black bars left/right
|
||||
val space = ((viewWidth - (viewHeight.toFloat() * aspectRatio.toFloat())) / 2).toInt()
|
||||
Rect(
|
||||
space,
|
||||
0,
|
||||
(viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space,
|
||||
viewHeight
|
||||
)
|
||||
Rect(space, 0, (viewHeight.toFloat() * aspectRatio.toFloat()).toInt() + space, viewHeight)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio)
|
||||
|
||||
|
||||
sourceRectHint?.let { builder.setSourceRectHint(it) }
|
||||
|
||||
// On Android 12+, enable auto-enter (like Findroid)
|
||||
|
||||
ensurePiPReceiverRegistered()
|
||||
builder.setActions(buildPiPActions())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(true)
|
||||
builder.setAutoEnterEnabled(forEntering || playbackRate > 0)
|
||||
}
|
||||
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
|
||||
private fun getActivity(): Activity? {
|
||||
// First try Expo's AppContext (preferred in React Native)
|
||||
appContext?.currentActivity?.let { return it }
|
||||
|
||||
// Fallback: Try to get from context wrapper chain
|
||||
|
||||
var ctx = context
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) {
|
||||
return ctx
|
||||
}
|
||||
if (ctx is Activity) return ctx
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PiP action (called from activity when user taps PiP controls)
|
||||
*/
|
||||
fun handlePiPAction(action: String) {
|
||||
when (action) {
|
||||
"play" -> delegate?.onPlay()
|
||||
"pause" -> delegate?.onPause()
|
||||
"skip_forward" -> delegate?.onSeekBy(10.0)
|
||||
"skip_backward" -> delegate?.onSeekBy(-10.0)
|
||||
|
||||
// MARK: - Lifecycle-based PiP Detection
|
||||
|
||||
private fun registerLifecycleCallbacks() {
|
||||
if (lifecycleRegistered) return
|
||||
|
||||
val app = context.applicationContext as? Application ?: run {
|
||||
Log.w(TAG, "Cannot access Application for lifecycle callbacks, falling back to polling")
|
||||
startFallbackPolling()
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (!isInPiPMode) return
|
||||
if (!activity.isInPictureInPictureMode) {
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
// Proactively hide controls when user leaves while playing,
|
||||
// before the PiP window captures the UI. onActivityStopped
|
||||
// will restore if PiP didn't actually enter.
|
||||
if (playbackRate > 0 && !isInPiPMode) {
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
pipHandler.postDelayed({
|
||||
val inPip = activity.isInPictureInPictureMode
|
||||
|
||||
if (inPip && !isInPiPMode) {
|
||||
isInPiPMode = true
|
||||
pipEntryNotified = true
|
||||
delegate?.onPictureInPictureModeChanged(true)
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
if (!isInPiPMode) return@postDelayed
|
||||
if (inPip) return@postDelayed
|
||||
|
||||
// Not in PiP after 1s — check again to avoid false positive during transition
|
||||
pipHandler.postDelayed({
|
||||
if (!isInPiPMode) return@postDelayed
|
||||
if (!activity.isInPictureInPictureMode) {
|
||||
isInPiPMode = false
|
||||
pipEntryNotified = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
}
|
||||
}, 1500)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
isInPiPMode = false
|
||||
}
|
||||
}
|
||||
|
||||
app.registerActivityLifecycleCallbacks(lifecycleCallbacks)
|
||||
lifecycleRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterLifecycleCallbacks() {
|
||||
if (!lifecycleRegistered) return
|
||||
lifecycleCallbacks?.let {
|
||||
(context.applicationContext as? Application)
|
||||
?.unregisterActivityLifecycleCallbacks(it)
|
||||
}
|
||||
lifecycleCallbacks = null
|
||||
lifecycleRegistered = false
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
unregisterPiPBroadcastReceiver()
|
||||
}
|
||||
|
||||
private fun startFallbackPolling() {
|
||||
var falseReadCount = 0
|
||||
pipHandler.removeCallbacksAndMessages(null)
|
||||
pipHandler.postDelayed(object : Runnable {
|
||||
override fun run() {
|
||||
if (!isInPiPMode) return
|
||||
|
||||
var ctx = context
|
||||
var activity: Activity? = null
|
||||
while (ctx is android.content.ContextWrapper) {
|
||||
if (ctx is Activity) { activity = ctx; break }
|
||||
ctx = ctx.baseContext
|
||||
}
|
||||
|
||||
val stillInPip = activity?.isInPictureInPictureMode == true
|
||||
|
||||
if (!stillInPip) {
|
||||
falseReadCount++
|
||||
if (falseReadCount >= 3) {
|
||||
isInPiPMode = false
|
||||
delegate?.onPictureInPictureModeChanged(false)
|
||||
return
|
||||
}
|
||||
pipHandler.postDelayed(this, 500)
|
||||
return
|
||||
}
|
||||
|
||||
falseReadCount = 0
|
||||
pipHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// MARK: - PiP Remote Actions
|
||||
|
||||
private fun ensurePiPReceiverRegistered() {
|
||||
if (pipBroadcastReceiver != null) return
|
||||
|
||||
pipBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
ACTION_PIP_PLAY_PAUSE -> {
|
||||
if (playbackRate > 0) delegate?.onPause() else delegate?.onPlay()
|
||||
}
|
||||
ACTION_PIP_SKIP_FORWARD -> delegate?.onSeekBy(10.0)
|
||||
ACTION_PIP_SKIP_BACKWARD -> delegate?.onSeekBy(-10.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PIP_PLAY_PAUSE)
|
||||
addAction(ACTION_PIP_SKIP_FORWARD)
|
||||
addAction(ACTION_PIP_SKIP_BACKWARD)
|
||||
}
|
||||
val registerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Context.RECEIVER_EXPORTED
|
||||
} else {
|
||||
0
|
||||
}
|
||||
context.applicationContext.registerReceiver(pipBroadcastReceiver, filter, registerFlags)
|
||||
}
|
||||
|
||||
private fun unregisterPiPBroadcastReceiver() {
|
||||
pipBroadcastReceiver?.let {
|
||||
try {
|
||||
context.applicationContext.unregisterReceiver(it)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
pipBroadcastReceiver = null
|
||||
}
|
||||
|
||||
private fun buildPiPActions(): List<RemoteAction> {
|
||||
val isPlaying = playbackRate > 0
|
||||
|
||||
return listOf(
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_media_rew),
|
||||
"Rewind", "Skip backward 10 seconds",
|
||||
createPiPPendingIntent(ACTION_PIP_SKIP_BACKWARD)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(
|
||||
context,
|
||||
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
|
||||
),
|
||||
if (isPlaying) "Pause" else "Play",
|
||||
if (isPlaying) "Pause playback" else "Resume playback",
|
||||
createPiPPendingIntent(ACTION_PIP_PLAY_PAUSE)
|
||||
),
|
||||
RemoteAction(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_media_ff),
|
||||
"Fast Forward", "Skip forward 10 seconds",
|
||||
createPiPPendingIntent(ACTION_PIP_SKIP_FORWARD)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPiPPendingIntent(action: String): android.app.PendingIntent {
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return android.app.PendingIntent.getBroadcast(
|
||||
context.applicationContext, 0, Intent(action), flags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -485,8 +485,7 @@ final class MPVLayerRenderer {
|
||||
switch event.event_id {
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
// Add external subtitles now that the file is loaded
|
||||
let hadExternalSubs = !pendingExternalSubtitles.isEmpty
|
||||
if hadExternalSubs, let handle = mpv {
|
||||
if !pendingExternalSubtitles.isEmpty, let handle = mpv {
|
||||
for (index, subUrl) in pendingExternalSubtitles.enumerated() {
|
||||
print("🔧 Adding external subtitle [\(index)]: \(subUrl)")
|
||||
// Use commandSync to ensure subs are added in exact order (not async)
|
||||
@@ -494,12 +493,20 @@ final class MPVLayerRenderer {
|
||||
commandSync(handle, ["sub-add", subUrl, "auto"])
|
||||
}
|
||||
pendingExternalSubtitles = []
|
||||
// Set subtitle after external subs are added
|
||||
if let subId = initialSubtitleId {
|
||||
setSubtitleTrack(subId)
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
}
|
||||
// Apply the initial audio/subtitle selection now that the file's
|
||||
// tracks are enumerated. Setting sid/aid before `loadfile` does not
|
||||
// reliably stick for embedded tracks (the selection is silently
|
||||
// dropped), so we (re)apply here for embedded and external alike.
|
||||
// This is what makes a carried-over subtitle show up on the next
|
||||
// episode without a manual re-selection.
|
||||
if let audioId = initialAudioId, audioId > 0 {
|
||||
setAudioTrack(audioId)
|
||||
}
|
||||
if let subId = initialSubtitleId {
|
||||
setSubtitleTrack(subId)
|
||||
} else {
|
||||
disableSubtitles()
|
||||
}
|
||||
if !isReadyToSeek {
|
||||
isReadyToSeek = true
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPVKit for Expo'
|
||||
s.description = 'MPVKit for Expo'
|
||||
s.author = 'mpvkit'
|
||||
s.homepage = 'https://github.com/mpvkit/MPVKit'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: 'https://github.com/mpvkit/MPVKit.git' }
|
||||
s.name = 'MpvPlayer'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'MPV-based video player for Streamyfin (Expo module)'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = { :ios => '15.1', :tvos => '15.1' }
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.dependency 'MPVKit-GPL'
|
||||
s.dependency 'MPVKit'
|
||||
|
||||
# Swift/Objective-C compatibility
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'VALID_ARCHS' => 'arm64',
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
|
||||
'DEBUG_INFORMATION_FORMAT' => 'dwarf',
|
||||
'STRIP_INSTALLED_PRODUCT' => 'YES',
|
||||
'DEPLOYMENT_POSTPROCESSING' => 'YES',
|
||||
}
|
||||
|
||||
s.user_target_xcconfig = {
|
||||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386'
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
|
||||
|
||||
@@ -76,6 +76,7 @@ class MpvPlayerView: ExpoView {
|
||||
super.init(appContext: appContext)
|
||||
setupNotifications()
|
||||
setupView()
|
||||
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
|
||||
}
|
||||
|
||||
private func setupView() {
|
||||
|
||||
@@ -150,6 +150,16 @@ final class PiPController: NSObject {
|
||||
CMTimebaseSetRate(tb, rate: Float64(rate))
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let tb = timebase {
|
||||
CMTimebaseSetRate(tb, rate: 0)
|
||||
}
|
||||
sampleBufferDisplayLayer?.controlTimebase = nil
|
||||
timebase = nil
|
||||
pipController?.delegate = nil
|
||||
pipController = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
@@ -25,6 +25,10 @@ export type OnErrorEventPayload = {
|
||||
|
||||
export type OnTracksReadyEventPayload = Record<string, never>;
|
||||
|
||||
export type OnPictureInPictureChangePayload = {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type NowPlayingMetadata = {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
@@ -77,6 +81,9 @@ export type MpvPlayerViewProps = {
|
||||
onProgress?: (event: { nativeEvent: OnProgressEventPayload }) => void;
|
||||
onError?: (event: { nativeEvent: OnErrorEventPayload }) => void;
|
||||
onTracksReady?: (event: { nativeEvent: OnTracksReadyEventPayload }) => void;
|
||||
onPictureInPictureChange?: (event: {
|
||||
nativeEvent: OnPictureInPictureChangePayload;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export interface MpvPlayerViewRef {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { MpvPlayerViewProps, MpvPlayerViewRef } from "./MpvPlayer.types";
|
||||
const NativeView: React.ComponentType<MpvPlayerViewProps & { ref?: any }> =
|
||||
requireNativeView("MpvPlayer");
|
||||
|
||||
const PIP_LOG = "[PiP] MpvPlayerView.tsx:";
|
||||
|
||||
export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
function MpvPlayerView(props, ref) {
|
||||
const nativeRef = useRef<any>(null);
|
||||
@@ -40,16 +42,24 @@ export default React.forwardRef<MpvPlayerViewRef, MpvPlayerViewProps>(
|
||||
return await nativeRef.current?.getDuration();
|
||||
},
|
||||
startPictureInPicture: async () => {
|
||||
console.log(PIP_LOG, "startPictureInPicture → native");
|
||||
await nativeRef.current?.startPictureInPicture();
|
||||
console.log(PIP_LOG, "startPictureInPicture ← native returned");
|
||||
},
|
||||
stopPictureInPicture: async () => {
|
||||
console.log(PIP_LOG, "stopPictureInPicture → native");
|
||||
await nativeRef.current?.stopPictureInPicture();
|
||||
console.log(PIP_LOG, "stopPictureInPicture ← native returned");
|
||||
},
|
||||
isPictureInPictureSupported: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureSupported();
|
||||
const result = await nativeRef.current?.isPictureInPictureSupported();
|
||||
console.log(PIP_LOG, "isPictureInPictureSupported =", result);
|
||||
return result;
|
||||
},
|
||||
isPictureInPictureActive: async () => {
|
||||
return await nativeRef.current?.isPictureInPictureActive();
|
||||
const result = await nativeRef.current?.isPictureInPictureActive();
|
||||
console.log(PIP_LOG, "isPictureInPictureActive =", result);
|
||||
return result;
|
||||
},
|
||||
getSubtitleTracks: async () => {
|
||||
return await nativeRef.current?.getSubtitleTracks();
|
||||
|
||||
6
modules/tv-search/expo-module.config.json
Normal file
6
modules/tv-search/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["TvSearchModule"]
|
||||
}
|
||||
}
|
||||
2
modules/tv-search/index.ts
Normal file
2
modules/tv-search/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as TvSearchView } from "./src/TvSearchView";
|
||||
export * from "./src/TvSearchView.types";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user