mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-30 18:48:30 +01:00
Compare commits
21 Commits
chore/sdk-
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2166bb3867 | ||
|
|
f9b71ef648 | ||
|
|
d11fb3d0c0 | ||
|
|
dd3ca37108 | ||
|
|
b588195456 | ||
|
|
fe4d90df26 | ||
|
|
a190122eea | ||
|
|
0bf8fac079 | ||
|
|
37b51abd34 | ||
|
|
6fe464088b | ||
|
|
769c7a2432 | ||
|
|
62c86533b1 | ||
|
|
4fc78f006d | ||
|
|
ab0957044f | ||
|
|
407ef3f51e | ||
|
|
0e531da2e0 | ||
|
|
1cabbf087e | ||
|
|
0f86c776ba | ||
|
|
07b79de203 | ||
|
|
4cc11403f8 | ||
|
|
0ba3f44615 |
95
.github/pull_request_template.md
vendored
95
.github/pull_request_template.md
vendored
@@ -1,91 +1,54 @@
|
|||||||
<!--
|
<!--
|
||||||
Pull Request Template for Streamyfin
|
Use a conventional commit title for the PR title,
|
||||||
====================================
|
for example `feat(auth): add MFA`
|
||||||
Use this template to help reviewers understand the purpose of your PR
|
All sections below are required. Write N/A if a section is not applicable.
|
||||||
and to ensure all necessary checks are completed before merging.
|
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
|
# 📦 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.
|
A short description of the changes and why you're making them.
|
||||||
Example:
|
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
|
||||||
“Add real-time currency conversion widget to dashboard.”
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 🏷️ Ticket / Issue
|
## 🏷️ Ticket / Issue
|
||||||
<!--
|
<!--
|
||||||
Link to the related ticket, issue or user story.
|
Link to the related ticket, issue or user story.
|
||||||
You can also indicate if this PR supersedes a previous one.
|
Example: Fixes #123
|
||||||
Example:
|
|
||||||
- Closes #123
|
|
||||||
- Fixes STREAMYFIN-456
|
|
||||||
- Resolves #789
|
|
||||||
- Supersedes #120
|
|
||||||
- Related: #130
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## 🛠️ 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)
|
### 🖼️ 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
|
## ✅ Checklist
|
||||||
<!--
|
<!--
|
||||||
Review and check off items as you complete them.
|
Review and check off items as you complete them.
|
||||||
-->
|
-->
|
||||||
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
- [ ] I’ve read the [contribution guidelines](CONTRIBUTING.md)
|
||||||
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
|
- [ ] Verified that changes behave as expected for all platforms
|
||||||
- [ ] Type checks pass (tsc/biome/etc.)
|
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
|
||||||
- [ ] Docs updated (README/ADR/usage/API)
|
- [ ] No secrets, hardcoded credentials, or private config files are included
|
||||||
- [ ] No secrets/credentials included; env vars documented
|
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
|
||||||
- [ ] Release notes/CHANGELOG entry added (if applicable)
|
|
||||||
- [ ] Verified locally that changes behave as expected
|
|
||||||
|
|
||||||
## 🔍 Testing Instructions
|
## 🔍 Testing Instructions
|
||||||
<!--
|
<!--
|
||||||
Describe how reviewers can test your changes.
|
Describe how reviewers can test your changes. This will help the PR get merged faster.
|
||||||
Example:
|
Example:
|
||||||
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
|
1. Open the settings page and scroll to the bottom
|
||||||
2. Install deps: `npm|pnpm|yarn|bun install`
|
2. Verify that the clear data button is visible and pressable
|
||||||
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
|
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
|
||||||
4. Run tests: `npm|pnpm|yarn|bun test`
|
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
|
||||||
5. Verification steps:
|
|
||||||
- [ ] Expected UI/endpoint behavior
|
|
||||||
- [ ] Logs show no errors
|
|
||||||
- [ ] Edge cases covered (list)
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## ⚙️ Deployment Notes
|
|
||||||
<!--
|
|
||||||
Describe any deployment considerations such as config, environment vars, or native builds.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## 📝 Additional Notes
|
|
||||||
<!--
|
|
||||||
Any other information or references related to this PR.
|
|
||||||
-->
|
|
||||||
19
.github/renovate.json
vendored
19
.github/renovate.json
vendored
@@ -25,25 +25,6 @@
|
|||||||
"osvVulnerabilityAlerts": true,
|
"osvVulnerabilityAlerts": true,
|
||||||
"configMigration": true,
|
"configMigration": true,
|
||||||
"separateMinorPatch": true,
|
"separateMinorPatch": true,
|
||||||
"customManagers": [
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"managerFilePatterns": ["/\\.ya?ml$/"],
|
|
||||||
"matchStrings": [
|
|
||||||
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
|
|
||||||
],
|
|
||||||
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"customDatasources": {
|
|
||||||
"xcode": {
|
|
||||||
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
|
|
||||||
"format": "json",
|
|
||||||
"transformTemplates": [
|
|
||||||
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lockFileMaintenance": {
|
"lockFileMaintenance": {
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
12
.github/workflows/build-apps.yml
vendored
12
.github/workflows/build-apps.yml
vendored
@@ -218,8 +218,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -283,8 +282,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
@@ -343,8 +341,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🏗️ Setup EAS
|
- name: 🏗️ Setup EAS
|
||||||
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
|
||||||
@@ -411,8 +408,7 @@ jobs:
|
|||||||
- name: 🔧 Setup Xcode
|
- name: 🔧 Setup Xcode
|
||||||
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
|
||||||
with:
|
with:
|
||||||
# renovate: datasource=custom.xcode depName=xcode versioning=loose
|
xcode-version: "26.2"
|
||||||
xcode-version: "26.4"
|
|
||||||
|
|
||||||
- name: 🚀 Build iOS app
|
- name: 🚀 Build iOS app
|
||||||
env:
|
env:
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -74,3 +74,5 @@ modules/background-downloader/android/build/*
|
|||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
.claude/
|
.claude/
|
||||||
|
.agents/skills/**
|
||||||
|
skills-lock.json
|
||||||
|
|||||||
7
app.json
7
app.json
@@ -78,16 +78,15 @@
|
|||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "16.4",
|
"deploymentTarget": "15.6",
|
||||||
"useFrameworks": "static",
|
"useFrameworks": "static"
|
||||||
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
|
||||||
"compileSdkVersion": 36,
|
"compileSdkVersion": 36,
|
||||||
"targetSdkVersion": 35,
|
"targetSdkVersion": 35,
|
||||||
"buildToolsVersion": "35.0.0",
|
"buildToolsVersion": "35.0.0",
|
||||||
"kotlinVersion": "2.1.20",
|
"kotlinVersion": "2.0.21",
|
||||||
"minSdkVersion": 26,
|
"minSdkVersion": 26,
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"packagingOptions": {
|
"packagingOptions": {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface MenuLink {
|
|||||||
icon: string;
|
icon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function menuLinks() {
|
export default function CustomLinksPage() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
|
|||||||
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
export default function favorites() {
|
export default function FavoritesPage() {
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import useRouter from "@/hooks/useAppRouter";
|
|||||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
@@ -47,15 +48,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
title: t("home.downloads.downloads_title"),
|
title: t("home.downloads.downloads_title"),
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -66,15 +59,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -104,15 +89,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -123,15 +100,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -142,15 +111,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -161,15 +122,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -180,15 +133,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -199,15 +144,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -237,15 +174,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -256,15 +185,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -275,15 +196,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -294,15 +207,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -313,15 +218,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -332,15 +229,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -351,15 +240,7 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable
|
|
||||||
onPress={() => _router.back()}
|
|
||||||
className='pl-0.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
@@ -369,11 +250,7 @@ export default function IndexLayout() {
|
|||||||
name='collections/[collectionId]'
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerLeft: () => (
|
headerLeft: () => <HeaderBackButton />,
|
||||||
<Pressable onPress={() => _router.back()} className='pl-0.5'>
|
|
||||||
<Feather name='chevron-left' size={28} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
),
|
|
||||||
headerShown: !Platform.isTV,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
|
|||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
import { writeToLog } from "@/utils/log";
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
export default function page() {
|
export default function DownloadsPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [_queue, _setQueue] = useAtom(queueAtom);
|
const [_queue, _setQueue] = useAtom(queueAtom);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
|
|||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import { formatTimeString } from "@/utils/time";
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
|
||||||
export default function page() {
|
export default function SessionsPage() {
|
||||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getProgressPercentage = () => {
|
const getProgressPercentage = () => {
|
||||||
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
if (!session.NowPlayingItem?.RunTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,17 +59,17 @@ function SettingsMobile() {
|
|||||||
|
|
||||||
<QuickConnect className='mb-4' />
|
<QuickConnect className='mb-4' />
|
||||||
|
|
||||||
<TouchableOpacity
|
<View className='mb-4'>
|
||||||
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
|
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||||
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
|
<ListItem
|
||||||
>
|
onPress={() =>
|
||||||
<Text className='text-white font-bold text-base mb-1'>
|
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||||
{t("pairing.pair_with_phone_title")}
|
}
|
||||||
</Text>
|
title={t("pairing.pair_with_phone")}
|
||||||
<Text className='text-neutral-400 text-sm'>
|
textColor='blue'
|
||||||
{t("pairing.pair_with_phone_description")}
|
/>
|
||||||
</Text>
|
</ListGroup>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
|
||||||
<View className='mb-4'>
|
<View className='mb-4'>
|
||||||
<AppLanguageSelector />
|
<AppLanguageSelector />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function AppearanceHideLibrariesPage() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function HideLibrariesPage() {
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrPluginPage() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function KefinTweaksPage() {
|
||||||
const { pluginSettings } = useSettings();
|
const { pluginSettings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
|
|||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function MarlinSearchPage() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
|
|||||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function page() {
|
export default function StreamystatsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ const Page: React.FC = () => {
|
|||||||
ItemFields.MediaStreams,
|
ItemFields.MediaStreams,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Lazily preload item with full media sources in background
|
// Lazily preload item with full media sources in background — never cache
|
||||||
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
|
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/utils/jellyseerr/server/models/Search";
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrCompanyPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|||||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrGenrePage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
|||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
|
||||||
export default function page() {
|
export default function JellyseerrPersonPage() {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Slot, Stack, withLayoutContext } from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createMaterialTopTabNavigator,
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "expo-router/js-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "@react-navigation/native";
|
||||||
|
import { Slot, Stack, withLayoutContext } from "expo-router";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function LiveTvChannelsPage() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const _insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
|
|||||||
|
|
||||||
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
|
||||||
|
|
||||||
export default function page() {
|
export default function LiveTvGuidePage() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
export default function page() {
|
export default function LiveTvRecordingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className='flex items-center justify-center h-full -mt-12'>
|
<View className='flex items-center justify-center h-full -mt-12'>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
|
|||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
@@ -104,6 +105,10 @@ const Page = () => {
|
|||||||
|
|
||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
// Fallback refresh for newly added content when returning to the library
|
||||||
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
|
useRefreshLibraryOnFocus();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
|
||||||
import {
|
import {
|
||||||
createMaterialTopTabNavigator,
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "expo-router/js-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "@react-navigation/native";
|
||||||
|
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useNavigation, useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useRoute } from "@react-navigation/native";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useRoute } from "expo-router/react-navigation";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const exampleSearches = [
|
|||||||
"The Mandalorian",
|
"The Mandalorian",
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function search() {
|
export default function SearchPage() {
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -221,7 +221,7 @@ export default function search() {
|
|||||||
|
|
||||||
const ids = response1.data.ids;
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
if (!ids || !ids.length) {
|
if (!ids?.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {
|
|||||||
type NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
type NativeBottomTabNavigationOptions,
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
import { withLayoutContext } from "expo-router";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "expo-router/react-navigation";
|
} from "@react-navigation/native";
|
||||||
|
import { withLayoutContext } from "expo-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import { writeToLog } from "@/utils/log";
|
|||||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||||
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
import { generateDeviceProfile } from "../../../utils/profiles/native";
|
||||||
|
|
||||||
export default function page() {
|
export default function DirectPlayerPage() {
|
||||||
const videoRef = useRef<MpvPlayerViewRef>(null);
|
const videoRef = useRef<MpvPlayerViewRef>(null);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -317,7 +317,7 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result: Stream | null = null;
|
let result: Stream | null = null;
|
||||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
if (offline && downloadedItem?.mediaSource) {
|
||||||
const url = downloadedItem.videoFilePath;
|
const url = downloadedItem.videoFilePath;
|
||||||
if (item) {
|
if (item) {
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
@@ -1248,7 +1248,7 @@ const styles = StyleSheet.create({
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
downloadingOverlay: {
|
downloadingOverlay: {
|
||||||
...StyleSheet.absoluteFill,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
borderRadius: scaleSize(14),
|
borderRadius: scaleSize(14),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import "@/augmentations";
|
|||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
|
||||||
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
import { onlineManager, QueryClient } from "@tanstack/react-query";
|
||||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import * as BackgroundTask from "expo-background-task";
|
import * as BackgroundTask from "expo-background-task";
|
||||||
import * as Device from "expo-device";
|
import * as Device from "expo-device";
|
||||||
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { GlobalModal } from "@/components/GlobalModal";
|
import { GlobalModal } from "@/components/GlobalModal";
|
||||||
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
|
||||||
@@ -395,8 +395,9 @@ function Layout() {
|
|||||||
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
|
||||||
dehydrateOptions: {
|
dehydrateOptions: {
|
||||||
shouldDehydrateQuery: (query) => {
|
shouldDehydrateQuery: (query) => {
|
||||||
// Only persist successful queries
|
return (
|
||||||
return query.state.status === "success";
|
query.state.status === "success" && query.options.gcTime !== 0
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/*",
|
"**/*",
|
||||||
|
|||||||
29
bun-patches/@react-native%2Fcodegen@0.83.6.patch
Normal file
29
bun-patches/@react-native%2Fcodegen@0.83.6.patch
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
diff --git a/lib/generators/modules/GenerateModuleObjCpp/index.js b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||||
|
index 927711514d2deaa3c795fb98e676e0a1f596eddc..0364d66204a76fccd3e06a0dc72bf801aa04a50d 100644
|
||||||
|
--- a/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||||
|
+++ b/lib/generators/modules/GenerateModuleObjCpp/index.js
|
||||||
|
@@ -67,9 +67,12 @@ const HeaderFileTemplate = ({
|
||||||
|
* must have a single output. More files => more genrule()s => slower builds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
-#ifndef __cplusplus
|
||||||
|
-#error This file must be compiled as Obj-C++. If you are importing it, you must change your file extension to .mm.
|
||||||
|
-#endif
|
||||||
|
+// Patched: guard the Obj-C++ body with __cplusplus instead of hard-#error-ing.
|
||||||
|
+// With use_frameworks! :static + New Arch, plain Obj-C .m TUs can trigger a
|
||||||
|
+// Clang module build (via Swift-interop -Swift.h umbrellas) that pulls this
|
||||||
|
+// header in Obj-C mode. Skipping the body (instead of erroring) lets the module
|
||||||
|
+// build; .mm consumers still get the full Obj-C++ contents unchanged.
|
||||||
|
+#if defined(__cplusplus)
|
||||||
|
|
||||||
|
// Avoid multiple includes of ${headerFileNameWithNoExt} symbols
|
||||||
|
#ifndef ${headerFileNameWithNoExt}_H
|
||||||
|
@@ -93,7 +96,7 @@ const HeaderFileTemplate = ({
|
||||||
|
structInlineMethods +
|
||||||
|
(assumeNonnull ? '\nNS_ASSUME_NONNULL_END\n' : '\n') +
|
||||||
|
`#endif // ${headerFileNameWithNoExt}_H` +
|
||||||
|
- '\n'
|
||||||
|
+ '\n#endif // defined(__cplusplus)\n'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const SourceFileTemplate = ({headerFileName, moduleImplementations}) => `/**
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
diff --git a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
|
||||||
index 09be306d5aa39337c5114c2ad6ba7513218e0751..24ff8ee2c36fef8632a7e012514fd04db9bf89fd 100644
|
|
||||||
--- a/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
|
||||||
+++ b/ios/Sources/Extensions+Helpers/RCTView+Helpers.swift
|
|
||||||
@@ -25,15 +25,14 @@ public extension RCTView {
|
|
||||||
return rootView.recursivelyFindSubview(whereType: targetType);
|
|
||||||
};
|
|
||||||
|
|
||||||
- var closestParentReactContentView: RCTRootContentView? {
|
|
||||||
- let targetType = RCTRootContentView.self;
|
|
||||||
-
|
|
||||||
- if let match = self.recursivelyFindParentView(whereType: targetType) {
|
|
||||||
- return match;
|
|
||||||
- };
|
|
||||||
-
|
|
||||||
- guard let rootView = self.rootViewForCurrentWindow else { return nil };
|
|
||||||
- return rootView.recursivelyFindSubview(whereType: targetType);
|
|
||||||
+ // PATCH (streamyfin): RCTRootContentView is a legacy paper class that the prebuilt
|
|
||||||
+ // new-architecture React (RN 0.85) does not export, so any reference to it fails to
|
|
||||||
+ // link (Undefined symbols: _OBJC_CLASS_$_RCTRootContentView). The app runs the new
|
|
||||||
+ // architecture, where this content-view lookup is unused; short-circuit to nil.
|
|
||||||
+ // Return type widened to RCTView? so the caller's `.reactTouchHandlers` (an RCTView
|
|
||||||
+ // extension) still resolves.
|
|
||||||
+ var closestParentReactContentView: RCTView? {
|
|
||||||
+ return nil;
|
|
||||||
};
|
|
||||||
|
|
||||||
var reactTouchHandlers: [RCTTouchHandler]? {
|
|
||||||
191
bun-patches/react-native-screens@4.18.0.patch
Normal file
191
bun-patches/react-native-screens@4.18.0.patch
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
diff --git a/node_modules/react-native-screens/.bun-tag-10a3b0add1bd4de6 b/.bun-tag-10a3b0add1bd4de6
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/node_modules/react-native-screens/.bun-tag-6a8504b742d5cfff b/.bun-tag-6a8504b742d5cfff
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/node_modules/react-native-screens/.bun-tag-d28396854bc27a3d b/.bun-tag-d28396854bc27a3d
|
||||||
|
new file mode 100644
|
||||||
|
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
|
||||||
|
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
|
||||||
|
index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
|
||||||
|
--- a/ios/RNSScreenStack.mm
|
||||||
|
+++ b/ios/RNSScreenStack.mm
|
||||||
|
@@ -34,6 +34,11 @@
|
||||||
|
#import "integrations/RNSDismissibleModalProtocol.h"
|
||||||
|
#import "utils/UINavigationBar+RNSUtility.h"
|
||||||
|
|
||||||
|
+#if TARGET_OS_TV
|
||||||
|
+#import <React/RCTTVNavigationEventNotification.h>
|
||||||
|
+#import <React/RCTTVRemoteHandler.h>
|
||||||
|
+#endif // TARGET_OS_TV
|
||||||
|
+
|
||||||
|
#ifdef RNS_GAMMA_ENABLED
|
||||||
|
#import "RNSFrameCorrectionProvider.h"
|
||||||
|
#import "Swift-Bridging.h"
|
||||||
|
@@ -43,6 +48,12 @@
|
||||||
|
namespace react = facebook::react;
|
||||||
|
#endif // RCT_NEW_ARCH_ENABLED
|
||||||
|
|
||||||
|
+#if TARGET_OS_TV
|
||||||
|
+@interface RNSNavigationController ()
|
||||||
|
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
|
||||||
|
+@end
|
||||||
|
+#endif // TARGET_OS_TV
|
||||||
|
+
|
||||||
|
@interface RNSScreenStackView () <
|
||||||
|
UINavigationControllerDelegate,
|
||||||
|
UIAdaptivePresentationControllerDelegate,
|
||||||
|
@@ -62,6 +73,57 @@ namespace react = facebook::react;
|
||||||
|
|
||||||
|
@implementation RNSNavigationController
|
||||||
|
|
||||||
|
+#if TARGET_OS_TV
|
||||||
|
+- (void)viewDidLoad
|
||||||
|
+{
|
||||||
|
+ [super viewDidLoad];
|
||||||
|
+
|
||||||
|
+ self.rnscreens_menuGestureRecognizer =
|
||||||
|
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
|
||||||
|
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
|
||||||
|
+
|
||||||
|
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||||
|
+ selector:@selector(rnscreens_enableMenuGesture)
|
||||||
|
+ name:RCTTVEnableMenuKeyNotification
|
||||||
|
+ object:nil];
|
||||||
|
+ [[NSNotificationCenter defaultCenter] addObserver:self
|
||||||
|
+ selector:@selector(rnscreens_disableMenuGesture)
|
||||||
|
+ name:RCTTVDisableMenuKeyNotification
|
||||||
|
+ object:nil];
|
||||||
|
+
|
||||||
|
+ if ([RCTTVRemoteHandler useMenuKey]) {
|
||||||
|
+ [self rnscreens_enableMenuGesture];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)dealloc
|
||||||
|
+{
|
||||||
|
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rnscreens_enableMenuGesture
|
||||||
|
+{
|
||||||
|
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||||
|
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rnscreens_disableMenuGesture
|
||||||
|
+{
|
||||||
|
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
|
||||||
|
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
|
||||||
|
+{
|
||||||
|
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
|
||||||
|
+ keyAction:recognizer.eventKeyAction
|
||||||
|
+ tag:nil
|
||||||
|
+ target:nil];
|
||||||
|
+}
|
||||||
|
+#endif // TARGET_OS_TV
|
||||||
|
+
|
||||||
|
#if !TARGET_OS_TV
|
||||||
|
- (UIViewController *)childViewControllerForStatusBarStyle
|
||||||
|
{
|
||||||
|
diff --git a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||||
|
index 95c76ccf3528d3a8828e90b272a1d79b0828a139..f29d4df21440d23523ae7a2f6fe71c32154e3928 100644
|
||||||
|
--- a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||||
|
+++ b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
|
||||||
|
@@ -79,11 +79,13 @@ class RNSSplitViewAppearanceApplicator {
|
||||||
|
maxWidth: splitView.maximumSupplementaryColumnWidth)
|
||||||
|
|
||||||
|
#if compiler(>=6.2)
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
validateColumnConstraints(
|
||||||
|
minWidth: splitView.minimumInspectorColumnWidth,
|
||||||
|
maxWidth: splitView.maximumInspectorColumnWidth)
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Step 2.2 - applying updates to columns
|
||||||
|
@@ -126,6 +128,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6.2)
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
if splitView.minimumSecondaryColumnWidth >= 0 {
|
||||||
|
splitViewController.minimumSecondaryColumnWidth = splitView.minimumSecondaryColumnWidth
|
||||||
|
@@ -159,6 +162,7 @@ class RNSSplitViewAppearanceApplicator {
|
||||||
|
splitView.preferredInspectorColumnWidthOrFraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Step 2.3 - manipulating with inspector column
|
||||||
|
diff --git a/ios/gamma/split-view/RNSSplitViewHostController.swift b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||||
|
index 0421e3ea92fc7bcdf57417b5ee3a62348fce34f5..cd878ab638d3c78a661e2df4c4c1b21011dfcf48 100644
|
||||||
|
--- a/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||||
|
+++ b/ios/gamma/split-view/RNSSplitViewHostController.swift
|
||||||
|
@@ -386,7 +386,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
/// @param inspectors An array of inspector-type RNSSplitViewScreenComponentView subviews.
|
||||||
|
///
|
||||||
|
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
|
||||||
|
-
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
let inspector = inspectors.first
|
||||||
|
if inspector != nil {
|
||||||
|
@@ -395,6 +395,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
setViewController(inspectorViewController, for: .inspector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
@@ -404,9 +405,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
|
||||||
|
///
|
||||||
|
func maybeShowInspector() {
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
show(.inspector)
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
@@ -416,9 +419,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
|
||||||
|
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
|
||||||
|
///
|
||||||
|
func maybeHideInspector() {
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
hide(.inspector)
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
@@ -444,6 +449,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||||
|
public func splitViewController(
|
||||||
|
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
|
||||||
|
) {
|
||||||
|
+ #if !os(tvOS)
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
|
||||||
|
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
|
||||||
|
@@ -461,6 +467,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
+ #endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type Href } from "expo-router";
|
import { type Href } from "expo-router";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -199,9 +200,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const downloadDetailsPromises = items.map(async (item) => {
|
const downloadDetailsPromises = items.map(async (item) => {
|
||||||
|
// Ensure the snapshot we store offline carries the Chapters array.
|
||||||
|
// Page-level fetches sometimes use a fields filter that omits it; the
|
||||||
|
// offline player would then render no chapter ticks / list.
|
||||||
|
let itemForDownload = item;
|
||||||
|
if (!itemForDownload.Chapters && itemForDownload.Id) {
|
||||||
|
try {
|
||||||
|
const enriched = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: itemForDownload.Id,
|
||||||
|
userId: user.Id!,
|
||||||
|
});
|
||||||
|
if (enriched.data) {
|
||||||
|
itemForDownload = enriched.data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { mediaSource, audioIndex, subtitleIndex } =
|
const { mediaSource, audioIndex, subtitleIndex } =
|
||||||
itemsNotDownloaded.length > 1
|
itemsNotDownloaded.length > 1
|
||||||
? getDefaultPlaySettings(item, settings!)
|
? getDefaultPlaySettings(itemForDownload, settings!)
|
||||||
: {
|
: {
|
||||||
mediaSource: selectedOptions?.mediaSource,
|
mediaSource: selectedOptions?.mediaSource,
|
||||||
audioIndex: selectedOptions?.audioIndex,
|
audioIndex: selectedOptions?.audioIndex,
|
||||||
@@ -210,7 +232,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
const downloadDetails = await getDownloadUrl({
|
const downloadDetails = await getDownloadUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item: itemForDownload,
|
||||||
userId: user.Id!,
|
userId: user.Id!,
|
||||||
mediaSource: mediaSource!,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex ?? -1,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
@@ -222,7 +244,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
url: downloadDetails?.url,
|
url: downloadDetails?.url,
|
||||||
item,
|
item: itemForDownload,
|
||||||
mediaSource: downloadDetails?.mediaSource,
|
mediaSource: downloadDetails?.mediaSource,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { Button, Host, Menu } from "@expo/ui/swift-ui";
|
||||||
Button,
|
import { disabled } from "@expo/ui/swift-ui/modifiers";
|
||||||
Host,
|
|
||||||
Menu,
|
|
||||||
Picker,
|
|
||||||
Text as SwiftUIText,
|
|
||||||
} from "@expo/ui/swift-ui";
|
|
||||||
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
@@ -262,24 +256,28 @@ const PlatformDropdownComponent = ({
|
|||||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||||
|
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
|
// 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 (
|
return (
|
||||||
<View>
|
<View style={triggerSize ?? { opacity: 0 }}>
|
||||||
{/* Hidden measurer: lays the trigger out normally to capture its
|
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
||||||
intrinsic size, which we then pin onto the Host below. */}
|
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
||||||
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
|
sizes to the trigger's content rather than to its parent. */}
|
||||||
<View
|
<View
|
||||||
style={{ alignSelf: "flex-start" }}
|
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
||||||
onLayout={handleMeasureTrigger}
|
pointerEvents='none'
|
||||||
>
|
aria-hidden
|
||||||
{trigger}
|
onLayout={handleMeasureTrigger}
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Host
|
|
||||||
style={[
|
|
||||||
triggerSize ?? { opacity: 0 },
|
|
||||||
expoUIConfig?.hostStyle as any,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
|
{trigger}
|
||||||
|
</View>
|
||||||
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
||||||
<Menu label={trigger}>
|
<Menu label={trigger}>
|
||||||
{groups.flatMap((group, groupIndex) => {
|
{groups.flatMap((group, groupIndex) => {
|
||||||
// Check if this group has radio options
|
// Check if this group has radio options
|
||||||
@@ -295,41 +293,40 @@ const PlatformDropdownComponent = ({
|
|||||||
|
|
||||||
const items = [];
|
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
|
// Otherwise render as individual buttons
|
||||||
if (radioOptions.length > 0) {
|
if (radioOptions.length > 0) {
|
||||||
if (group.title) {
|
if (group.title) {
|
||||||
// Use Picker for grouped options.
|
// Use a nested Menu as a submenu for grouped options. This
|
||||||
// Use the option index (a stable primitive) as the
|
// reads as "Title: Selected" and expands to the choices on
|
||||||
// tag/selection value and React key. Option `value`s can be
|
// tap, keeping the nested look while staying a dropdown.
|
||||||
// objects (e.g. bitrate / media source), which collapse to
|
// (Menu opens on a single tap and nests cleanly; ContextMenu
|
||||||
// "[object Object]" as a key and never match the Picker's
|
// would require a long-press and read as a context menu.)
|
||||||
// primitive selection.
|
const selectedOption = radioOptions.find(
|
||||||
const selectedRadioIndex = radioOptions.findIndex(
|
|
||||||
(opt) => opt.selected,
|
(opt) => opt.selected,
|
||||||
);
|
);
|
||||||
|
const displayTitle = selectedOption
|
||||||
|
? `${group.title}: ${selectedOption.label}`
|
||||||
|
: group.title;
|
||||||
items.push(
|
items.push(
|
||||||
<Picker
|
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
|
||||||
key={`picker-${groupIndex}`}
|
{radioOptions.map((option, optionIndex) => (
|
||||||
label={group.title}
|
<Button
|
||||||
selection={
|
|
||||||
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
|
|
||||||
}
|
|
||||||
onSelectionChange={(index) => {
|
|
||||||
const selectedOption = radioOptions[index as number];
|
|
||||||
selectedOption?.onPress();
|
|
||||||
onOptionSelect?.(selectedOption?.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{radioOptions.map((opt, optionIndex) => (
|
|
||||||
<SwiftUIText
|
|
||||||
key={`radio-${groupIndex}-${optionIndex}`}
|
key={`radio-${groupIndex}-${optionIndex}`}
|
||||||
modifiers={[tag(optionIndex)]}
|
label={option.label}
|
||||||
>
|
systemImage={
|
||||||
{opt.label}
|
option.selected ? "checkmark.circle.fill" : "circle"
|
||||||
</SwiftUIText>
|
}
|
||||||
|
modifiers={
|
||||||
|
option.disabled ? [disabled(true)] : undefined
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
option.onPress();
|
||||||
|
onOptionSelect?.(option.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Picker>,
|
</Menu>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Render radio options as direct buttons
|
// Render radio options as direct buttons
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item?.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!item?.RunTimeTicks) return 0;
|
||||||
const userData = item.UserData;
|
const userData = item.UserData;
|
||||||
if (userData?.PlaybackPositionTicks) {
|
if (userData?.PlaybackPositionTicks) {
|
||||||
return userData.PlaybackPositionTicks > 0
|
return userData.PlaybackPositionTicks > 0
|
||||||
|
|||||||
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);
|
||||||
@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
|||||||
}, [process?.progress]);
|
}, [process?.progress]);
|
||||||
|
|
||||||
// Return null after all hooks have been called
|
// Return null after all hooks have been called
|
||||||
if (!process || !process.item || !process.item.Id) {
|
if (!process?.item?.Id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
|
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||||
@@ -89,6 +90,10 @@ const HomeMobile = () => {
|
|||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
const { showIntro } = useIntroSheet();
|
const { showIntro } = useIntroSheet();
|
||||||
|
|
||||||
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
|
useRefreshLibraryOnFocus();
|
||||||
|
|
||||||
// Show intro modal on first launch
|
// Show intro modal on first launch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
|
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +87,10 @@ export const Home = () => {
|
|||||||
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const { showItemActions } = useTVItemActionModal();
|
const { showItemActions } = useTVItemActionModal();
|
||||||
|
|
||||||
|
// Fallback refresh for newly added content when returning to the home screen
|
||||||
|
// (primary path is the LibraryChanged WebSocket event).
|
||||||
|
useRefreshLibraryOnFocus();
|
||||||
|
|
||||||
// Dynamic backdrop state with debounce
|
// Dynamic backdrop state with debounce
|
||||||
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
|||||||
|
|
||||||
// Get subtitle for episodes
|
// Get subtitle for episodes
|
||||||
const episodeSubtitle = useMemo(() => {
|
const episodeSubtitle = useMemo(() => {
|
||||||
if (!activeItem || activeItem.Type !== "Episode") return null;
|
if (activeItem?.Type !== "Episode") return null;
|
||||||
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||||
}, [activeItem]);
|
}, [activeItem]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Fetch programs for visible channels
|
||||||
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
|
const { data: programsData } = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"livetv",
|
"livetv",
|
||||||
"tv-guide",
|
"tv-guide",
|
||||||
|
|||||||
@@ -382,16 +382,18 @@ export const Login: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='flex flex-col flex-1 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'>
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
<Image
|
<Image
|
||||||
style={{
|
style={{
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
marginBottom: -20,
|
marginBottom: -20,
|
||||||
alignSelf: "center",
|
|
||||||
}}
|
}}
|
||||||
source={require("@/assets/images/icon-ios-plain.png")}
|
source={require("@/assets/images/icon-ios-plain.png")}
|
||||||
/>
|
/>
|
||||||
@@ -429,8 +431,6 @@ export const Login: React.FC = () => {
|
|||||||
await handleConnect(server.address);
|
await handleConnect(server.address);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<View className='px-4 pb-2'>
|
|
||||||
<PreviousServersList
|
<PreviousServersList
|
||||||
onServerSelect={async (s) => {
|
onServerSelect={async (s) => {
|
||||||
await handleConnect(s.address);
|
await handleConnect(s.address);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv";
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||||
import { scaleSize } from "@/utils/scaleSize";
|
import { scaleSize } from "@/utils/scaleSize";
|
||||||
|
|
||||||
interface TVPasswordEntryModalProps {
|
interface TVPasswordEntryModalProps {
|
||||||
@@ -201,6 +202,13 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
|||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
|
// Close the modal on the TV remote back/menu button while it is open.
|
||||||
|
useTVBackPress(() => {
|
||||||
|
if (!visible) return false;
|
||||||
|
onClose();
|
||||||
|
return true;
|
||||||
|
}, [visible, onClose]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
setError(t("password.enter_password"));
|
setError(t("password.enter_password"));
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { Button, Host, Menu } from "@expo/ui/swift-ui";
|
||||||
Button,
|
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
|
||||||
ContextMenu,
|
|
||||||
Host,
|
|
||||||
Picker,
|
|
||||||
Text as SwiftUIText,
|
|
||||||
} from "@expo/ui/swift-ui";
|
|
||||||
import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers";
|
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
@@ -47,42 +41,54 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
|||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContextMenu>
|
<Menu
|
||||||
<ContextMenu.Trigger>
|
label={
|
||||||
<Button
|
<Button
|
||||||
modifiers={[buttonStyle("glass")]}
|
modifiers={[buttonStyle("glass")]}
|
||||||
systemImage='line.3.horizontal.decrease.circle'
|
systemImage='line.3.horizontal.decrease.circle'
|
||||||
></Button>
|
/>
|
||||||
</ContextMenu.Trigger>
|
}
|
||||||
<ContextMenu.Items>
|
>
|
||||||
<Picker
|
<Menu
|
||||||
label={t("library.filters.sort_by")}
|
label={`${t("library.filters.sort_by")}: ${t(
|
||||||
selection={jellyseerrOrderBy as unknown as string}
|
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
|
||||||
onSelectionChange={(value) => {
|
)}`}
|
||||||
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
|
>
|
||||||
}}
|
{sortOptions.map((item) => {
|
||||||
>
|
const isSelected =
|
||||||
{sortOptions.map((item) => (
|
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
|
||||||
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
return (
|
||||||
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
<Button
|
||||||
</SwiftUIText>
|
key={item}
|
||||||
))}
|
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
||||||
</Picker>
|
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
||||||
<Picker
|
onPress={() =>
|
||||||
label={t("library.filters.sort_order")}
|
setJellyseerrOrderBy(
|
||||||
selection={jellyseerrSortOrder}
|
item as unknown as JellyseerrSearchSort,
|
||||||
onSelectionChange={(value) => {
|
)
|
||||||
setJellyseerrSortOrder(value as "asc" | "desc");
|
}
|
||||||
}}
|
/>
|
||||||
>
|
);
|
||||||
{orderOptions.map((item) => (
|
})}
|
||||||
<SwiftUIText key={item} modifiers={[tag(item)]}>
|
</Menu>
|
||||||
{t(`library.filters.${item}`)}
|
<Menu
|
||||||
</SwiftUIText>
|
label={`${t("library.filters.sort_order")}: ${t(
|
||||||
))}
|
`library.filters.${jellyseerrSortOrder}`,
|
||||||
</Picker>
|
)}`}
|
||||||
</ContextMenu.Items>
|
>
|
||||||
</ContextMenu>
|
{orderOptions.map((item) => {
|
||||||
|
const isSelected = jellyseerrSortOrder === item;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item}
|
||||||
|
label={t(`library.filters.${item}`)}
|
||||||
|
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
|
||||||
|
onPress={() => setJellyseerrSortOrder(item)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
</Menu>
|
||||||
</Host>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button, Host } from "@expo/ui/swift-ui";
|
import { Button, Host, HStack, Spacer } from "@expo/ui/swift-ui";
|
||||||
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
|
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
@@ -19,16 +18,8 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios") {
|
||||||
return (
|
return (
|
||||||
<>
|
<Host style={{ height: 40, flex: 1 }}>
|
||||||
<Host
|
<HStack spacing={8}>
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
width: 80,
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 10,
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
modifiers={[
|
modifiers={[
|
||||||
buttonStyle(
|
buttonStyle(
|
||||||
@@ -36,19 +27,8 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
|||||||
),
|
),
|
||||||
]}
|
]}
|
||||||
onPress={() => setSearchType("Library")}
|
onPress={() => setSearchType("Library")}
|
||||||
>
|
label={t("search.library")}
|
||||||
<Text>{t("search.library")}</Text>
|
/>
|
||||||
</Button>
|
|
||||||
</Host>
|
|
||||||
<Host
|
|
||||||
style={{
|
|
||||||
height: 40,
|
|
||||||
width: 100,
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 10,
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
modifiers={[
|
modifiers={[
|
||||||
buttonStyle(
|
buttonStyle(
|
||||||
@@ -56,11 +36,11 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
|
|||||||
),
|
),
|
||||||
]}
|
]}
|
||||||
onPress={() => setSearchType("Discover")}
|
onPress={() => setSearchType("Discover")}
|
||||||
>
|
label={t("search.discover")}
|
||||||
<Text>{t("search.discover")}</Text>
|
/>
|
||||||
</Button>
|
<Spacer />
|
||||||
</Host>
|
</HStack>
|
||||||
</>
|
</Host>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Stack } from "expo-router";
|
import type { ParamListBase, RouteProp } from "@react-navigation/native";
|
||||||
import type { ComponentProps } from "react";
|
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { HeaderBackButton } from "../common/HeaderBackButton";
|
import { HeaderBackButton } from "../common/HeaderBackButton";
|
||||||
|
|
||||||
type ICommonScreenOptions = ComponentProps<typeof Stack.Screen>["options"];
|
type ICommonScreenOptions =
|
||||||
|
| NativeStackNavigationOptions
|
||||||
|
| ((prop: {
|
||||||
|
route: RouteProp<ParamListBase, string>;
|
||||||
|
navigation: any;
|
||||||
|
}) => NativeStackNavigationOptions);
|
||||||
|
|
||||||
export const commonScreenOptions: ICommonScreenOptions = {
|
export const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
const typography = useScaledTVTypography();
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
|
const cancelled = useSharedValue(false);
|
||||||
const onFinishRef = useRef(onFinish);
|
const onFinishRef = useRef(onFinish);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({
|
useTVFocusAnimation({
|
||||||
@@ -120,13 +121,15 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelled.value = false;
|
||||||
|
|
||||||
// Resume from current position
|
// Resume from current position
|
||||||
const remainingDuration = (1 - progress.value) * 8000;
|
const remainingDuration = (1 - progress.value) * 8000;
|
||||||
progress.value = withTiming(
|
progress.value = withTiming(
|
||||||
1,
|
1,
|
||||||
{ duration: remainingDuration, easing: Easing.linear },
|
{ duration: remainingDuration, easing: Easing.linear },
|
||||||
(finished) => {
|
(finished) => {
|
||||||
if (finished) {
|
if (finished && !cancelled.value) {
|
||||||
runOnJS(onFinishRef.current)();
|
runOnJS(onFinishRef.current)();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -134,9 +137,10 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
|
|
||||||
// Cancel animation on unmount to prevent onFinish from firing after exit
|
// Cancel animation on unmount to prevent onFinish from firing after exit
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled.value = true;
|
||||||
cancelAnimation(progress);
|
cancelAnimation(progress);
|
||||||
};
|
};
|
||||||
}, [show, isPlaying, progress]);
|
}, [show, isPlaying, progress, cancelled]);
|
||||||
|
|
||||||
const progressStyle = useAnimatedStyle(() => ({
|
const progressStyle = useAnimatedStyle(() => ({
|
||||||
width: `${progress.value * 100}%`,
|
width: `${progress.value * 100}%`,
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
|||||||
color: "#fff",
|
color: "#fff",
|
||||||
},
|
},
|
||||||
downloadingOverlay: {
|
downloadingOverlay: {
|
||||||
...StyleSheet.absoluteFill,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
borderRadius: scaleSize(14),
|
borderRadius: scaleSize(14),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type { FC } from "react";
|
import type {
|
||||||
import { View } from "react-native";
|
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 { Slider } from "react-native-awesome-slider";
|
||||||
import { type SharedValue } from "react-native-reanimated";
|
import { type SharedValue } from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
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 { Text } from "@/components/common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { ChapterMarkers } from "./ChapterMarkers";
|
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
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 {
|
interface BottomControlsProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
|
/** Item chapters, used for the tick overlay and chapter list. */
|
||||||
|
chapters?: ChapterInfo[] | null;
|
||||||
|
/** Total media duration in milliseconds. */
|
||||||
|
durationMs: number;
|
||||||
showControls: boolean;
|
showControls: boolean;
|
||||||
isSliding: boolean;
|
isSliding: boolean;
|
||||||
showRemoteBubble: boolean;
|
showRemoteBubble: boolean;
|
||||||
@@ -39,6 +54,8 @@ interface BottomControlsProps {
|
|||||||
handleSliderChange: (value: number) => void;
|
handleSliderChange: (value: number) => void;
|
||||||
handleTouchStart: () => void;
|
handleTouchStart: () => void;
|
||||||
handleTouchEnd: () => void;
|
handleTouchEnd: () => void;
|
||||||
|
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
|
||||||
|
seekTo: (value: number) => void;
|
||||||
|
|
||||||
// Trickplay props
|
// Trickplay props
|
||||||
trickPlayUrl: {
|
trickPlayUrl: {
|
||||||
@@ -65,6 +82,8 @@ interface BottomControlsProps {
|
|||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
item,
|
item,
|
||||||
|
chapters,
|
||||||
|
durationMs,
|
||||||
showControls,
|
showControls,
|
||||||
isSliding,
|
isSliding,
|
||||||
showRemoteBubble,
|
showRemoteBubble,
|
||||||
@@ -88,13 +107,39 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
handleSliderChange,
|
handleSliderChange,
|
||||||
handleTouchStart,
|
handleTouchStart,
|
||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
|
seekTo,
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
chapterPositions = [],
|
chapterPositions = [],
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -136,8 +181,24 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
{item?.Type === "Audio" && (
|
{item?.Type === "Audio" && (
|
||||||
<Text className='text-xs opacity-50'>{item?.Album}</Text>
|
<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>
|
||||||
<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
|
<SkipButton
|
||||||
showButton={showSkipButton}
|
showButton={showSkipButton}
|
||||||
onPress={skipIntro}
|
onPress={skipIntro}
|
||||||
@@ -181,7 +242,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
height: 10,
|
height: 10,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "stretch",
|
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}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
@@ -209,6 +272,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={time}
|
time={time}
|
||||||
|
chapterName={scrubChapterName}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -218,7 +282,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
<ChapterMarkers chapterPositions={chapterPositions} />
|
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
|
||||||
</View>
|
</View>
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
@@ -226,6 +290,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<ChapterList
|
||||||
|
visible={chapterListVisible}
|
||||||
|
chapters={chapters}
|
||||||
|
currentPositionMs={currentTime}
|
||||||
|
onSeek={seekTo}
|
||||||
|
onClose={() => setChapterListVisible(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ export const Controls: FC<Props> = ({
|
|||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
handleSliderComplete,
|
handleSliderComplete,
|
||||||
handleSliderChange,
|
handleSliderChange,
|
||||||
|
seekTo,
|
||||||
} = useVideoSlider({
|
} = useVideoSlider({
|
||||||
progress,
|
progress,
|
||||||
isSeeking,
|
isSeeking,
|
||||||
@@ -555,6 +556,8 @@ export const Controls: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<BottomControls
|
<BottomControls
|
||||||
item={item}
|
item={item}
|
||||||
|
chapters={item.Chapters}
|
||||||
|
durationMs={maxMs}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
isSliding={isSliding}
|
isSliding={isSliding}
|
||||||
showRemoteBubble={showRemoteBubble}
|
showRemoteBubble={showRemoteBubble}
|
||||||
@@ -578,6 +581,7 @@ export const Controls: FC<Props> = ({
|
|||||||
handleSliderChange={handleSliderChange}
|
handleSliderChange={handleSliderChange}
|
||||||
handleTouchStart={handleTouchStart}
|
handleTouchStart={handleTouchStart}
|
||||||
handleTouchEnd={handleTouchEnd}
|
handleTouchEnd={handleTouchEnd}
|
||||||
|
seekTo={seekTo}
|
||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
|
|||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
|
import type { Track } from "./types";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -315,6 +316,31 @@ export const Controls: FC<Props> = ({
|
|||||||
[onSubtitleIndexChange],
|
[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 {
|
const {
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
calculateTrickplayUrl,
|
calculateTrickplayUrl,
|
||||||
@@ -491,6 +517,8 @@ export const Controls: FC<Props> = ({
|
|||||||
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
|
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
|
const exitingRef = useRef(false);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
|
||||||
const updateSeekBubbleTime = useCallback((ms: number) => {
|
const updateSeekBubbleTime = useCallback((ms: number) => {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
@@ -572,6 +600,9 @@ export const Controls: FC<Props> = ({
|
|||||||
disableTrack?.setTrack();
|
disableTrack?.setTrack();
|
||||||
},
|
},
|
||||||
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
|
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
|
||||||
|
refreshSubtitleTracks: onRefreshSubtitleTracks
|
||||||
|
? refreshSubtitleTracks
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
controlsInteractionRef.current();
|
controlsInteractionRef.current();
|
||||||
}, [
|
}, [
|
||||||
@@ -581,6 +612,8 @@ export const Controls: FC<Props> = ({
|
|||||||
videoContextSubtitleTracks,
|
videoContextSubtitleTracks,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
handleLocalSubtitleDownloaded,
|
handleLocalSubtitleDownloaded,
|
||||||
|
onRefreshSubtitleTracks,
|
||||||
|
refreshSubtitleTracks,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleToggleTechnicalInfo = useCallback(() => {
|
const handleToggleTechnicalInfo = useCallback(() => {
|
||||||
@@ -929,6 +962,16 @@ export const Controls: FC<Props> = ({
|
|||||||
router.back();
|
router.back();
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
const handleWillExit = useCallback(() => {
|
||||||
|
exitingRef.current = true;
|
||||||
|
setIsExiting(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancelExit = useCallback(() => {
|
||||||
|
exitingRef.current = false;
|
||||||
|
setIsExiting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { isSliding: isRemoteSliding } = useRemoteControl({
|
const { isSliding: isRemoteSliding } = useRemoteControl({
|
||||||
showControls: showControls,
|
showControls: showControls,
|
||||||
toggleControls,
|
toggleControls,
|
||||||
@@ -945,6 +988,8 @@ export const Controls: FC<Props> = ({
|
|||||||
onVerticalDpad: handleVerticalDpad,
|
onVerticalDpad: handleVerticalDpad,
|
||||||
onHideControls: hideControls,
|
onHideControls: hideControls,
|
||||||
onBack: handleBack,
|
onBack: handleBack,
|
||||||
|
onWillExit: handleWillExit,
|
||||||
|
onCancelExit: handleCancelExit,
|
||||||
videoTitle: item?.Name ?? undefined,
|
videoTitle: item?.Name ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1030,6 +1075,7 @@ export const Controls: FC<Props> = ({
|
|||||||
goToNextItemRef.current = goToNextItem;
|
goToNextItemRef.current = goToNextItem;
|
||||||
|
|
||||||
const handleAutoPlayFinish = useCallback(() => {
|
const handleAutoPlayFinish = useCallback(() => {
|
||||||
|
if (exitingRef.current) return;
|
||||||
goToNextItem({ isAutoPlay: true });
|
goToNextItem({ isAutoPlay: true });
|
||||||
}, [goToNextItem]);
|
}, [goToNextItem]);
|
||||||
|
|
||||||
@@ -1104,7 +1150,7 @@ export const Controls: FC<Props> = ({
|
|||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
api={api}
|
api={api}
|
||||||
show={isCountdownActive}
|
show={isCountdownActive}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying && !isExiting}
|
||||||
onFinish={handleAutoPlayFinish}
|
onFinish={handleAutoPlayFinish}
|
||||||
onPlayNext={handleNextItemButton}
|
onPlayNext={handleNextItemButton}
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
@@ -1408,14 +1454,14 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
controlsContainer: {
|
controlsContainer: {
|
||||||
...StyleSheet.absoluteFill,
|
...StyleSheet.absoluteFillObject,
|
||||||
},
|
},
|
||||||
darkOverlay: {
|
darkOverlay: {
|
||||||
...StyleSheet.absoluteFill,
|
...StyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||||
},
|
},
|
||||||
focusStealingOverlay: {
|
focusStealingOverlay: {
|
||||||
...StyleSheet.absoluteFill,
|
...StyleSheet.absoluteFillObject,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
bottomContainer: {
|
bottomContainer: {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { View } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
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_LEFT_OFFSET = 62;
|
||||||
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
|
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
@@ -28,12 +30,8 @@ interface TrickplayBubbleProps {
|
|||||||
};
|
};
|
||||||
/** Scale factor for the image (default 1). Does not affect timestamp text. */
|
/** Scale factor for the image (default 1). Does not affect timestamp text. */
|
||||||
imageScale?: number;
|
imageScale?: number;
|
||||||
}
|
/** Chapter name at the scrubbed position, if any. */
|
||||||
|
chapterName?: string | null;
|
||||||
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)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||||
@@ -41,6 +39,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
imageScale = 1,
|
imageScale = 1,
|
||||||
|
chapterName,
|
||||||
}) => {
|
}) => {
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
return null;
|
return null;
|
||||||
@@ -49,19 +48,28 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
const { x, y, url } = trickPlayUrl;
|
const { x, y, url } = trickPlayUrl;
|
||||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
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;
|
const finalScale = BASE_IMAGE_SCALE * imageScale;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
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,
|
left: -BUBBLE_LEFT_OFFSET * imageScale,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
paddingTop: 30,
|
paddingTop: 12,
|
||||||
paddingBottom: 5,
|
paddingBottom: 5,
|
||||||
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
// Bring the bubble in front of the player title / overlays.
|
||||||
|
zIndex: 999,
|
||||||
|
elevation: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -89,10 +97,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
source={{ uri: url }}
|
source={{ uri: url }}
|
||||||
contentFit='cover'
|
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>
|
</View>
|
||||||
<Text style={{ marginTop: 30, fontSize: 16 }}>
|
|
||||||
{formatTime(time.hours, time.minutes, time.seconds)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ interface UseRemoteControlProps {
|
|||||||
onLongSeekStop?: () => void;
|
onLongSeekStop?: () => void;
|
||||||
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
|
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
|
||||||
onVerticalDpad?: () => void;
|
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
|
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
|
||||||
// These are ignored in the simplified implementation
|
// These are ignored in the simplified implementation
|
||||||
progress?: SharedValue<number>;
|
progress?: SharedValue<number>;
|
||||||
@@ -72,6 +76,8 @@ export function useRemoteControl({
|
|||||||
onLongSeekRightStart,
|
onLongSeekRightStart,
|
||||||
onLongSeekStop,
|
onLongSeekStop,
|
||||||
onVerticalDpad,
|
onVerticalDpad,
|
||||||
|
onWillExit,
|
||||||
|
onCancelExit,
|
||||||
}: UseRemoteControlProps) {
|
}: UseRemoteControlProps) {
|
||||||
// Keep these for backward compatibility with the component
|
// Keep these for backward compatibility with the component
|
||||||
const remoteScrubProgress = useSharedValue<number | null>(null);
|
const remoteScrubProgress = useSharedValue<number | null>(null);
|
||||||
@@ -85,13 +91,24 @@ export function useRemoteControl({
|
|||||||
const onHideControlsRef = useRef(onHideControls);
|
const onHideControlsRef = useRef(onHideControls);
|
||||||
const onBackRef = useRef(onBack);
|
const onBackRef = useRef(onBack);
|
||||||
const videoTitleRef = useRef(videoTitle);
|
const videoTitleRef = useRef(videoTitle);
|
||||||
|
const onWillExitRef = useRef(onWillExit);
|
||||||
|
const onCancelExitRef = useRef(onCancelExit);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showControlsRef.current = showControls;
|
showControlsRef.current = showControls;
|
||||||
onHideControlsRef.current = onHideControls;
|
onHideControlsRef.current = onHideControls;
|
||||||
onBackRef.current = onBack;
|
onBackRef.current = onBack;
|
||||||
videoTitleRef.current = videoTitle;
|
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
|
// BackHandler owns player exit: Android TV sends hardware back here, and
|
||||||
// react-native-tvos maps the Apple TV menu button to the same API.
|
// react-native-tvos maps the Apple TV menu button to the same API.
|
||||||
@@ -102,6 +119,9 @@ export function useRemoteControl({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (onBackRef.current) {
|
if (onBackRef.current) {
|
||||||
|
// Signal Controls that exit is imminent (pauses countdown, sets guard)
|
||||||
|
onWillExitRef.current?.();
|
||||||
|
|
||||||
// Controls are hidden, so confirm before leaving playback.
|
// Controls are hidden, so confirm before leaving playback.
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Stop Playback",
|
"Stop Playback",
|
||||||
@@ -109,7 +129,11 @@ export function useRemoteControl({
|
|||||||
? `Stop playing "${videoTitleRef.current}"?`
|
? `Stop playing "${videoTitleRef.current}"?`
|
||||||
: "Are you sure you want to stop playback?",
|
: "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 },
|
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ export function useVideoSlider({
|
|||||||
[seek, play, progress, isSeeking],
|
[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(
|
const handleSliderChange = useCallback(
|
||||||
debounce((value: number) => {
|
debounce((value: number) => {
|
||||||
// Convert ms to ticks for trickplay
|
// Convert ms to ticks for trickplay
|
||||||
@@ -96,5 +111,6 @@ export function useVideoSlider({
|
|||||||
handleTouchEnd,
|
handleTouchEnd,
|
||||||
handleSliderComplete,
|
handleSliderComplete,
|
||||||
handleSliderChange,
|
handleSliderChange,
|
||||||
|
seekTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,4 @@ type Track = {
|
|||||||
localPath?: string;
|
localPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };
|
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExtraQueryOptions = {
|
||||||
|
gcTime?: number;
|
||||||
|
staleTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const useItemQuery = (
|
export const useItemQuery = (
|
||||||
itemId: string | undefined,
|
itemId: string | undefined,
|
||||||
isOffline?: boolean,
|
isOffline?: boolean,
|
||||||
fields?: ItemFields[],
|
fields?: ItemFields[],
|
||||||
excludeFields?: ItemFields[],
|
excludeFields?: ItemFields[],
|
||||||
|
queryOptions?: ExtraQueryOptions,
|
||||||
) => {
|
) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -56,5 +62,6 @@ export const useItemQuery = (
|
|||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
networkMode: "always",
|
networkMode: "always",
|
||||||
|
...queryOptions,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const usePlaybackManager = ({
|
|||||||
const { data: adjacentItems } = useQuery({
|
const { data: adjacentItems } = useQuery({
|
||||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||||
if (!item || !item.SeriesId) {
|
if (!item?.SeriesId) {
|
||||||
return null;
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["sessions"],
|
queryKey: ["sessions"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
if (!api || !user?.Policy?.IsAdministrator) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const response = await getSessionApi(api).getSessions({
|
const response = await getSessionApi(api).getSessions({
|
||||||
@@ -55,7 +55,7 @@ export const useAllSessions = ({
|
|||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["allSessions"],
|
queryKey: ["allSessions"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
if (!api || !user?.Policy?.IsAdministrator) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const response = await getSessionApi(api).getSessions({
|
const response = await getSessionApi(api).getSessions({
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["streamystats", "watchlists"],
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||||
});
|
});
|
||||||
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["streamystats", "watchlists"],
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ final class PiPController: NSObject {
|
|||||||
CMTimebaseSetRate(tb, rate: Float64(rate))
|
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
|
// MARK: - AVPictureInPictureControllerDelegate
|
||||||
|
|||||||
98
package.json
98
package.json
@@ -28,59 +28,61 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bottom-tabs/react-navigation": "1.2.0",
|
"@bottom-tabs/react-navigation": "1.2.0",
|
||||||
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
"@douglowder/expo-av-route-picker-view": "^0.0.5",
|
||||||
"@expo/metro-runtime": "~56.0.13",
|
"@expo/metro-runtime": "~55.0.11",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
"@expo/ui": "~56.0.14",
|
"@expo/ui": "~55.0.17",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
"@jellyfin/sdk": "^0.13.0",
|
"@jellyfin/sdk": "^0.13.0",
|
||||||
"@react-native-community/netinfo": "^12.0.0",
|
"@react-native-community/netinfo": "^12.0.0",
|
||||||
|
"@react-navigation/material-top-tabs": "7.4.28",
|
||||||
"@react-navigation/native": "^7.2.5",
|
"@react-navigation/native": "^7.2.5",
|
||||||
|
"@react-navigation/native-stack": "~7.14.5",
|
||||||
"@shopify/flash-list": "2.0.2",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
"@tanstack/query-sync-storage-persister": "^5.100.14",
|
||||||
"@tanstack/react-pacer": "^0.19.1",
|
"@tanstack/react-pacer": "^0.19.1",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-query-persist-client": "^5.100.14",
|
"@tanstack/react-query-persist-client": "^5.100.14",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"expo": "~56.0.6",
|
"expo": "~55.0.26",
|
||||||
"expo-application": "~56.0.3",
|
"expo-application": "~55.0.15",
|
||||||
"expo-asset": "~56.0.15",
|
"expo-asset": "~55.0.17",
|
||||||
"expo-audio": "~56.0.11",
|
"expo-audio": "~55.0.0",
|
||||||
"expo-background-task": "~56.0.15",
|
"expo-background-task": "~55.0.18",
|
||||||
"expo-blur": "~56.0.3",
|
"expo-blur": "~55.0.14",
|
||||||
"expo-brightness": "~56.0.5",
|
"expo-brightness": "~55.0.13",
|
||||||
"expo-build-properties": "~56.0.15",
|
"expo-build-properties": "~55.0.14",
|
||||||
"expo-camera": "~56.0.7",
|
"expo-camera": "~55.0.19",
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~55.0.16",
|
||||||
"expo-crypto": "~56.0.4",
|
"expo-crypto": "~55.0.15",
|
||||||
"expo-dev-client": "~56.0.16",
|
"expo-dev-client": "~55.0.35",
|
||||||
"expo-device": "~56.0.4",
|
"expo-device": "~55.0.17",
|
||||||
"expo-font": "~56.0.5",
|
"expo-font": "~55.0.8",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~55.0.14",
|
||||||
"expo-image": "~56.0.9",
|
"expo-image": "~55.0.11",
|
||||||
"expo-linear-gradient": "~56.0.4",
|
"expo-linear-gradient": "~55.0.14",
|
||||||
"expo-linking": "~56.0.12",
|
"expo-linking": "~55.0.15",
|
||||||
"expo-localization": "~56.0.6",
|
"expo-localization": "~55.0.15",
|
||||||
"expo-location": "~56.0.14",
|
"expo-location": "~55.1.10",
|
||||||
"expo-notifications": "~56.0.14",
|
"expo-notifications": "~55.0.23",
|
||||||
"expo-router": "~56.2.7",
|
"expo-router": "~55.0.16",
|
||||||
"expo-screen-orientation": "~56.0.5",
|
"expo-screen-orientation": "~55.0.16",
|
||||||
"expo-secure-store": "~56.0.4",
|
"expo-secure-store": "~55.0.14",
|
||||||
"expo-sharing": "~56.0.14",
|
"expo-sharing": "~55.0.20",
|
||||||
"expo-splash-screen": "~56.0.10",
|
"expo-splash-screen": "~55.0.21",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~55.0.6",
|
||||||
"expo-system-ui": "~56.0.5",
|
"expo-system-ui": "~55.0.18",
|
||||||
"expo-task-manager": "~56.0.15",
|
"expo-task-manager": "~55.0.16",
|
||||||
"expo-web-browser": "~56.0.5",
|
"expo-web-browser": "~55.0.16",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
"lodash": "4.18.1",
|
"lodash": "4.18.1",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.0",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-native": "npm:react-native-tvos@0.85.3-0",
|
"react-native": "npm:react-native-tvos@0.83.6-0",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.2.0",
|
"react-native-bottom-tabs": "1.2.0",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
"react-native-device-info": "^15.0.0",
|
"react-native-device-info": "^15.0.0",
|
||||||
"react-native-draggable-flatlist": "^4.0.3",
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
"react-native-edge-to-edge": "^1.7.0",
|
"react-native-edge-to-edge": "^1.7.0",
|
||||||
"react-native-gesture-handler": "~2.31.1",
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
"react-native-glass-effect-view": "^1.0.0",
|
"react-native-glass-effect-view": "^1.0.0",
|
||||||
"react-native-google-cast": "^4.9.1",
|
"react-native-google-cast": "^4.9.1",
|
||||||
"react-native-image-colors": "^2.4.0",
|
"react-native-image-colors": "^2.4.0",
|
||||||
@@ -97,13 +99,13 @@
|
|||||||
"react-native-ios-utilities": "5.2.0",
|
"react-native-ios-utilities": "5.2.0",
|
||||||
"react-native-mmkv": "4.1.1",
|
"react-native-mmkv": "4.1.1",
|
||||||
"react-native-nitro-modules": "0.33.1",
|
"react-native-nitro-modules": "0.33.1",
|
||||||
"react-native-pager-view": "8.0.1",
|
"react-native-pager-view": "8.0.0",
|
||||||
"react-native-qrcode-svg": "^6.3.21",
|
"react-native-qrcode-svg": "^6.3.21",
|
||||||
"react-native-reanimated": "4.3.1",
|
"react-native-reanimated": "4.2.1",
|
||||||
"react-native-reanimated-carousel": "4.0.3",
|
"react-native-reanimated-carousel": "4.0.3",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "4.25.2",
|
"react-native-screens": "~4.18.0",
|
||||||
"react-native-svg": "15.15.4",
|
"react-native-svg": "15.15.3",
|
||||||
"react-native-text-ticker": "^1.15.0",
|
"react-native-text-ticker": "^1.15.0",
|
||||||
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
"react-native-track-player": "github:lovegaoshi/react-native-track-player#APM",
|
||||||
"react-native-udp": "^4.1.7",
|
"react-native-udp": "^4.1.7",
|
||||||
@@ -111,19 +113,19 @@
|
|||||||
"react-native-uuid": "^2.0.3",
|
"react-native-uuid": "^2.0.3",
|
||||||
"react-native-volume-manager": "^2.0.8",
|
"react-native-volume-manager": "^2.0.8",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-worklets": "0.8.3",
|
"react-native-worklets": "0.7.4",
|
||||||
"sonner-native": "0.21.2",
|
"sonner-native": "0.21.2",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.29.7",
|
"@babel/core": "7.28.6",
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@react-native-community/cli": "20.1.3",
|
"@react-native-community/cli": "20.1.3",
|
||||||
"@react-native-tvos/config-tv": "0.1.6",
|
"@react-native-tvos/config-tv": "0.1.6",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lodash": "4.17.24",
|
"@types/lodash": "4.17.23",
|
||||||
"@types/react": "~19.2.10",
|
"@types/react": "~19.2.10",
|
||||||
"@types/react-test-renderer": "19.1.0",
|
"@types/react-test-renderer": "19.1.0",
|
||||||
"cross-env": "10.1.0",
|
"cross-env": "10.1.0",
|
||||||
@@ -146,7 +148,6 @@
|
|||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"react-native",
|
|
||||||
"react-native-screens"
|
"react-native-screens"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -164,8 +165,9 @@
|
|||||||
"unrs-resolver"
|
"unrs-resolver"
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
"react-native-screens@4.18.0": "bun-patches/react-native-screens@4.18.0.patch",
|
||||||
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
"react-native-udp@4.1.7": "bun-patches/react-native-udp@4.1.7.patch",
|
||||||
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch",
|
"@react-native/codegen@0.83.6": "bun-patches/@react-native%2Fcodegen@0.83.6.patch",
|
||||||
"react-native-ios-utilities@5.2.0": "bun-patches/react-native-ios-utilities@5.2.0.patch"
|
"react-native-bottom-tabs@1.2.0": "bun-patches/react-native-bottom-tabs@1.2.0.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,17 +25,6 @@ function buildPatch() {
|
|||||||
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
|
" cfg.build_settings['HEADER_SEARCH_PATHS'] ||= '$(inherited)'",
|
||||||
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
|
" cfg.build_settings['HEADER_SEARCH_PATHS'] << \" #{extra_hdrs.join(' ')}\"",
|
||||||
" cfg.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'",
|
" cfg.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'",
|
||||||
" # iOS 26 / Xcode 26: SwiftUI was split into SwiftUI + SwiftUICore. The SwiftUI",
|
|
||||||
" # pods (ExpoUI, glass-effect, glass-poster, …) emit a `-framework SwiftUICore`",
|
|
||||||
" # autolink directive that, under use_frameworks :static, flows into the app",
|
|
||||||
" # executable's link. The app isn't an allowed client of the private",
|
|
||||||
" # SwiftUICore.tbd → `cannot link directly with 'SwiftUICore'`. Dropping that one",
|
|
||||||
" # autolink at the Swift frontend lets the symbols resolve via SwiftUI's",
|
|
||||||
" # re-export instead. Phone-only — tvOS links fine and must stay untouched.",
|
|
||||||
" if ENV['EXPO_TV'] != '1'",
|
|
||||||
" cfg.build_settings['OTHER_SWIFT_FLAGS'] ||= '$(inherited)'",
|
|
||||||
" cfg.build_settings['OTHER_SWIFT_FLAGS'] << ' -Xfrontend -disable-autolink-framework -Xfrontend SwiftUICore'",
|
|
||||||
" end",
|
|
||||||
" end",
|
" end",
|
||||||
" end",
|
" end",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -12,9 +12,22 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { AppState, type AppStateStatus } from "react-native";
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||||
|
|
||||||
|
// Query keys that depend on the set of library items and should be refreshed
|
||||||
|
// when the server reports that the library changed (items added/removed/updated).
|
||||||
|
const LIBRARY_CHANGE_QUERY_KEYS = [
|
||||||
|
["home"],
|
||||||
|
["library-items"],
|
||||||
|
["nextUp-all"],
|
||||||
|
["nextUp"],
|
||||||
|
["resumeItems"],
|
||||||
|
["seasons"],
|
||||||
|
["episodes"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface WebSocketMessage {
|
interface WebSocketMessage {
|
||||||
MessageType: string;
|
MessageType: string;
|
||||||
Data: any;
|
Data: any;
|
||||||
@@ -42,10 +55,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const queryClient = useNetworkAwareQueryClient();
|
||||||
const deviceId = useMemo(() => {
|
const deviceId = useMemo(() => {
|
||||||
return getOrSetDeviceId();
|
return getOrSetDeviceId();
|
||||||
}, []);
|
}, []);
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(() => {
|
||||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
@@ -111,18 +128,57 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
};
|
};
|
||||||
}, [api, deviceId, isNetworkConnected]);
|
}, [api, deviceId, isNetworkConnected]);
|
||||||
|
|
||||||
|
const handleLibraryChanged = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
// Jellyfin sends LibraryChanged when a scan adds/updates/removes items.
|
||||||
|
// Only refresh when something actually changed in the item set.
|
||||||
|
const hasChanges =
|
||||||
|
(data?.ItemsAdded?.length ?? 0) > 0 ||
|
||||||
|
(data?.ItemsRemoved?.length ?? 0) > 0 ||
|
||||||
|
(data?.ItemsUpdated?.length ?? 0) > 0 ||
|
||||||
|
(data?.FoldersAddedTo?.length ?? 0) > 0 ||
|
||||||
|
(data?.FoldersRemovedFrom?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single scan can emit several LibraryChanged messages in quick
|
||||||
|
// succession, so debounce the invalidation to refetch only once.
|
||||||
|
if (libraryChangeDebounceRef.current) {
|
||||||
|
clearTimeout(libraryChangeDebounceRef.current);
|
||||||
|
}
|
||||||
|
libraryChangeDebounceRef.current = setTimeout(() => {
|
||||||
|
for (const queryKey of LIBRARY_CHANGE_QUERY_KEYS) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...queryKey] });
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastMessage) {
|
if (!lastMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (lastMessage.MessageType === "Play") {
|
if (lastMessage.MessageType === "Play") {
|
||||||
handlePlayCommand(lastMessage.Data);
|
handlePlayCommand(lastMessage.Data);
|
||||||
|
} else if (lastMessage.MessageType === "LibraryChanged") {
|
||||||
|
handleLibraryChanged(lastMessage.Data);
|
||||||
}
|
}
|
||||||
}, [lastMessage, router]);
|
}, [lastMessage, router, handleLibraryChanged]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (libraryChangeDebounceRef.current) {
|
||||||
|
clearTimeout(libraryChangeDebounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePlayCommand = useCallback(
|
const handlePlayCommand = useCallback(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
if (!data?.ItemIds?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +206,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
|||||||
}, [connectWebSocket]);
|
}, [connectWebSocket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deviceId || !api || !api?.accessToken || !isNetworkConnected) {
|
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -525,23 +525,10 @@ function displayBuildError(
|
|||||||
console.error(line);
|
console.error(line);
|
||||||
}
|
}
|
||||||
console.error("--- End Build Errors ---\n");
|
console.error("--- End Build Errors ---\n");
|
||||||
}
|
} else if (stdout.trim()) {
|
||||||
|
|
||||||
// Linker failures ("Undefined symbols for architecture …", the SwiftUICore
|
|
||||||
// autolink rejection, "ld: …") don't carry an "error:" token, so the pattern
|
|
||||||
// filter above drops the symbol name and "referenced from" context that
|
|
||||||
// actually pinpoints the culprit. Surface that block explicitly.
|
|
||||||
const stdoutLines = stdout.split("\n");
|
|
||||||
const undefIdx = stdoutLines.findIndex((line: string) =>
|
|
||||||
line.includes("Undefined symbols"),
|
|
||||||
);
|
|
||||||
if (undefIdx >= 0) {
|
|
||||||
console.error("\n--- Linker error detail ---");
|
|
||||||
console.error(stdoutLines.slice(undefIdx, undefIdx + 40).join("\n"));
|
|
||||||
console.error("--- End linker error detail ---\n");
|
|
||||||
} else if (errorLines.length === 0 && stdout.trim()) {
|
|
||||||
// No specific error patterns found, show last N lines of stdout
|
// No specific error patterns found, show last N lines of stdout
|
||||||
const lastLines = stdoutLines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n");
|
const lines = stdout.split("\n");
|
||||||
|
const lastLines = lines.slice(-ERROR_OUTPUT_TAIL_LINES).join("\n");
|
||||||
console.error(
|
console.error(
|
||||||
`\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`,
|
`\n--- Last ${ERROR_OUTPUT_TAIL_LINES} lines of build output ---`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -720,6 +720,12 @@
|
|||||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||||
"downloaded": "Downloaded"
|
"downloaded": "Downloaded"
|
||||||
},
|
},
|
||||||
|
"chapters": {
|
||||||
|
"title": "Chapters",
|
||||||
|
"chapter_number": "Chapter {{number}}",
|
||||||
|
"open": "Open chapters",
|
||||||
|
"close": "Close chapters"
|
||||||
|
},
|
||||||
"item_card": {
|
"item_card": {
|
||||||
"next_up": "Next Up",
|
"next_up": "Next Up",
|
||||||
"no_items_to_display": "No Items to Display",
|
"no_items_to_display": "No Items to Display",
|
||||||
|
|||||||
@@ -399,6 +399,14 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
|
|||||||
loadPluginSettings(),
|
loadPluginSettings(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasMeaningfulSettingValue = (value: unknown) =>
|
||||||
|
value !== undefined && value !== null && value !== "";
|
||||||
|
|
||||||
|
const getEffectiveSettingValue = <K extends keyof Settings>(
|
||||||
|
settings: Partial<Settings> | null | undefined,
|
||||||
|
settingsKey: K,
|
||||||
|
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
|
||||||
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const [_settings, setSettings] = useAtom(settingsAtom);
|
const [_settings, setSettings] = useAtom(settingsAtom);
|
||||||
@@ -439,12 +447,13 @@ export const useSettings = () => {
|
|||||||
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||||
if (setting && !setting.locked && setting.value !== undefined) {
|
if (setting && !setting.locked && setting.value !== undefined) {
|
||||||
const settingsKey = key as keyof Settings;
|
const settingsKey = key as keyof Settings;
|
||||||
// Apply if forceOverride is true, or if user hasn't explicitly set this value
|
const effectiveValue = getEffectiveSettingValue(
|
||||||
if (
|
_settings,
|
||||||
forceOverride ||
|
settingsKey,
|
||||||
_settings[settingsKey] === undefined ||
|
);
|
||||||
_settings[settingsKey] === ""
|
// Apply if forceOverride is true, or if neither persisted settings
|
||||||
) {
|
// nor app defaults provide a meaningful value.
|
||||||
|
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
|
||||||
(updates as any)[settingsKey] = setting.value;
|
(updates as any)[settingsKey] = setting.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,28 +505,22 @@ export const useSettings = () => {
|
|||||||
|
|
||||||
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
|
||||||
// If admin sets locked to false but provides a value,
|
// If admin sets locked to false but provides a value,
|
||||||
// use user settings first and fallback on admin setting if required.
|
// use persisted settings first, then app defaults, and only fallback on the
|
||||||
|
// plugin value when neither provides a meaningful value.
|
||||||
const settings: Settings = useMemo(() => {
|
const settings: Settings = useMemo(() => {
|
||||||
const unlockedPluginDefaults: Partial<Settings> = {};
|
|
||||||
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
|
||||||
Partial<Settings>
|
Partial<Settings>
|
||||||
>((acc, [key, setting]) => {
|
>((acc, [key, setting]) => {
|
||||||
if (setting) {
|
if (setting) {
|
||||||
const { value, locked } = setting;
|
const { value, locked } = setting;
|
||||||
const settingsKey = key as keyof Settings;
|
const settingsKey = key as keyof Settings;
|
||||||
|
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||||
// Make sure we override default settings with plugin settings when they are not locked.
|
|
||||||
if (
|
|
||||||
!locked &&
|
|
||||||
value !== undefined &&
|
|
||||||
_settings?.[settingsKey] !== value
|
|
||||||
) {
|
|
||||||
(unlockedPluginDefaults as any)[settingsKey] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
(acc as any)[settingsKey] = locked
|
(acc as any)[settingsKey] = locked
|
||||||
? value
|
? value
|
||||||
: (_settings?.[settingsKey] ?? value);
|
: hasMeaningfulSettingValue(effectiveValue)
|
||||||
|
? effectiveValue
|
||||||
|
: value;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|||||||
138
utils/chapters.test.ts
Normal file
138
utils/chapters.test.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
chapterMarkers,
|
||||||
|
chapterNameAt,
|
||||||
|
chapterStartsMs,
|
||||||
|
currentChapterIndex,
|
||||||
|
formatChapterTime,
|
||||||
|
sortedChapters,
|
||||||
|
} from "./chapters";
|
||||||
|
|
||||||
|
// Helper: a ChapterInfo with a start in milliseconds.
|
||||||
|
const ch = (ms: number, name?: string) => ({
|
||||||
|
StartPositionTicks: ms * 10000,
|
||||||
|
Name: name,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterMarkers", () => {
|
||||||
|
test("maps chapters to position + percent", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
{ positionMs: 30_000, percent: 25 },
|
||||||
|
{ positionMs: 60_000, percent: 50 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops chapters past the duration", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] when duration is 0 or chapters missing", () => {
|
||||||
|
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
|
||||||
|
expect(chapterMarkers(null, 120_000)).toEqual([]);
|
||||||
|
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("excludes a chapter exactly at the duration", () => {
|
||||||
|
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
|
||||||
|
{ positionMs: 0, percent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips chapters with no StartPositionTicks", () => {
|
||||||
|
expect(
|
||||||
|
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
|
||||||
|
).toEqual([{ positionMs: 30_000, percent: 25 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentChapterIndex", () => {
|
||||||
|
const chapters = [ch(0), ch(30_000), ch(60_000)];
|
||||||
|
test("returns the chapter containing the position", () => {
|
||||||
|
expect(currentChapterIndex(0, chapters)).toBe(0);
|
||||||
|
expect(currentChapterIndex(15_000, chapters)).toBe(0);
|
||||||
|
expect(currentChapterIndex(30_000, chapters)).toBe(1);
|
||||||
|
expect(currentChapterIndex(90_000, chapters)).toBe(2);
|
||||||
|
});
|
||||||
|
test("returns -1 before the first chapter and for no chapters", () => {
|
||||||
|
expect(currentChapterIndex(-5, chapters)).toBe(-1);
|
||||||
|
expect(currentChapterIndex(10_000, [])).toBe(-1);
|
||||||
|
expect(currentChapterIndex(10_000, null)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortedChapters", () => {
|
||||||
|
test("pairs each chapter with its ms start, sorted ascending", () => {
|
||||||
|
const a = ch(60_000, "C");
|
||||||
|
const b = ch(0, "A");
|
||||||
|
const c = ch(30_000, "B");
|
||||||
|
expect(sortedChapters([a, b, c])).toEqual([
|
||||||
|
{ chapter: b, positionMs: 0 },
|
||||||
|
{ chapter: c, positionMs: 30_000 },
|
||||||
|
{ chapter: a, positionMs: 60_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
test("returns [] for null/undefined", () => {
|
||||||
|
expect(sortedChapters(null)).toEqual([]);
|
||||||
|
expect(sortedChapters(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterStartsMs", () => {
|
||||||
|
test("returns sorted ms positions", () => {
|
||||||
|
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
|
||||||
|
0, 30_000, 60_000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entries without StartPositionTicks", () => {
|
||||||
|
expect(
|
||||||
|
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
|
||||||
|
).toEqual([0, 30_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns [] for null/undefined/empty", () => {
|
||||||
|
expect(chapterStartsMs(null)).toEqual([]);
|
||||||
|
expect(chapterStartsMs(undefined)).toEqual([]);
|
||||||
|
expect(chapterStartsMs([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chapterNameAt", () => {
|
||||||
|
const named = [
|
||||||
|
{ StartPositionTicks: 0, Name: "Intro" },
|
||||||
|
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
|
||||||
|
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("returns the chapter name for the active position", () => {
|
||||||
|
expect(chapterNameAt(0, named)).toBe("Intro");
|
||||||
|
expect(chapterNameAt(15_000, named)).toBe("Intro");
|
||||||
|
expect(chapterNameAt(45_000, named)).toBe("Action");
|
||||||
|
expect(chapterNameAt(90_000, named)).toBe("Outro");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null before the first chapter", () => {
|
||||||
|
expect(chapterNameAt(-1, named)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for null/undefined/empty chapters", () => {
|
||||||
|
expect(chapterNameAt(10_000, null)).toBeNull();
|
||||||
|
expect(chapterNameAt(10_000, undefined)).toBeNull();
|
||||||
|
expect(chapterNameAt(10_000, [])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when the active chapter has no Name", () => {
|
||||||
|
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatChapterTime", () => {
|
||||||
|
test("formats m:ss and h:mm:ss", () => {
|
||||||
|
expect(formatChapterTime(65_000)).toBe("1:05");
|
||||||
|
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
|
||||||
|
expect(formatChapterTime(-100)).toBe("0:00");
|
||||||
|
});
|
||||||
|
});
|
||||||
97
utils/chapters.ts
Normal file
97
utils/chapters.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
|
||||||
|
* unit-testable under `bun test`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { ticksToMs } from "@/utils/time";
|
||||||
|
|
||||||
|
export interface ChapterMarker {
|
||||||
|
/** Chapter start, in milliseconds. */
|
||||||
|
positionMs: number;
|
||||||
|
/** Chapter start as a percentage (0-100) of the media duration. */
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterEntry {
|
||||||
|
chapter: ChapterInfo;
|
||||||
|
/** Chapter start, in milliseconds. */
|
||||||
|
positionMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chapters paired with their millisecond start, sorted ascending by start. */
|
||||||
|
export const sortedChapters = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): ChapterEntry[] =>
|
||||||
|
(chapters ?? [])
|
||||||
|
.filter((c) => c.StartPositionTicks != null)
|
||||||
|
.map((chapter) => ({
|
||||||
|
chapter,
|
||||||
|
positionMs: ticksToMs(chapter.StartPositionTicks),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.positionMs - b.positionMs);
|
||||||
|
|
||||||
|
/** Chapter start positions in milliseconds, ascending. */
|
||||||
|
export const chapterStartsMs = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): number[] =>
|
||||||
|
(chapters ?? [])
|
||||||
|
.filter((c) => c.StartPositionTicks != null)
|
||||||
|
.map((c) => ticksToMs(c.StartPositionTicks))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
|
||||||
|
export const chapterMarkers = (
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
): ChapterMarker[] => {
|
||||||
|
if (durationMs <= 0) return [];
|
||||||
|
return chapterStartsMs(chapters)
|
||||||
|
.filter((ms) => ms >= 0 && ms < durationMs)
|
||||||
|
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
|
||||||
|
export const currentChapterIndex = (
|
||||||
|
positionMs: number,
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): number => {
|
||||||
|
const starts = chapterStartsMs(chapters);
|
||||||
|
let index = -1;
|
||||||
|
for (let i = 0; i < starts.length; i++) {
|
||||||
|
if (positionMs >= starts[i]) index = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
|
||||||
|
export const chapterNameAt = (
|
||||||
|
positionMs: number,
|
||||||
|
chapters: ChapterInfo[] | null | undefined,
|
||||||
|
): string | null => {
|
||||||
|
// Sort once, derive both the active index and the entry from the same array
|
||||||
|
// — `chapterNameAt` runs on every playback tick, so paying for one `sort()`
|
||||||
|
// instead of two is worth the duplication of the index loop here.
|
||||||
|
const sorted = sortedChapters(chapters);
|
||||||
|
let idx = -1;
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
if (positionMs >= sorted[i].positionMs) idx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const name = sorted[idx]?.chapter.Name;
|
||||||
|
return name && name.length > 0 ? name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
|
||||||
|
export const formatChapterTime = (positionMs: number): string => {
|
||||||
|
const total = Math.max(0, Math.floor(positionMs / 1000));
|
||||||
|
const hours = Math.floor(total / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return hours > 0
|
||||||
|
? `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||||
|
: `${minutes}:${pad(seconds)}`;
|
||||||
|
};
|
||||||
@@ -156,4 +156,4 @@ class StreamRanker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };
|
export { AudioStreamRanker, StreamRanker, SubtitleStreamRanker };
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { useFocusEffect } from "@react-navigation/core";
|
||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
type UseQueryOptions,
|
type UseQueryOptions,
|
||||||
type UseQueryResult,
|
type UseQueryResult,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useFocusEffect } from "expo-router/react-navigation";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
export function useReactNavigationQuery<
|
export function useReactNavigationQuery<
|
||||||
|
|||||||
Reference in New Issue
Block a user