Compare commits

..

1 Commits

Author SHA1 Message Date
Gauvain
fd87cb05ea ci: [test] enable tvOS builds, disable other jobs (throwaway branch) 2026-05-28 20:25:25 +02:00
140 changed files with 4402 additions and 14814 deletions

View File

@@ -1,54 +1,91 @@
<!--
Use a conventional commit title for the PR title,
for example `feat(auth): add MFA`
All sections below are required. Write N/A if a section is not applicable.
If you use AI to help implement this PR, you must declare it below. It's very important that the feature or fix implemented has been tested thoroughly by you personally on all target platforms. Only adding AI generated code without proper testing is not allowed and this PR will be closed immediately.
<!--
Pull Request Template for Streamyfin
====================================
Use this template to help reviewers understand the purpose of your PR
and to ensure all necessary checks are completed before merging.
-->
# 📦 Pull Request
<!--
🤖 AI ASSISTED?
Uncomment the line below if AI was used to assist with this PR:
-->
<!--
[![AI Assisted](https://img.shields.io/badge/AI_Assisted-18181b?style=for-the-badge&logo=openai&logoColor=white)](#) -->
## 📝 Description
## 🔖 Summary
<!--
A short description of the changes and why you're making them.
Example: “Add option to clean image cache, to mitigate stuck/blank movie poster issues.”
A concise description of the changes introduced by this PR.
Example:
“Add real-time currency conversion widget to dashboard.”
-->
## 🏷️ Ticket / Issue
<!--
Link to the related ticket, issue or user story.
Example: Fixes #123
You can also indicate if this PR supersedes a previous one.
Example:
- Closes #123
- Fixes STREAMYFIN-456
- Resolves #789
- Supersedes #120
- Related: #130
-->
### 🖼️ Screenshots / GIFs (if UI)
<!--
Include screenshots of relevant UI changes for both Android and iOS.
Before/After, responsive states (if relevant).
## 🛠️ Whats 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 (12 lines)
-->
## 📋 Details
<!--
Provide more context or background. Explain any non-obvious decisions.
Include screenshots or GIFs for UI changes if applicable.
-->
### ⚠️ Breaking Changes
<!-- List any breaking API/contract changes and migration guidance. If none, write “None”. -->
### 🔐 Security & Privacy Impact
<!-- Data touched, new permissions/scopes, PII, secrets, threat considerations. If none, write “None”. -->
### ⚡ Performance Impact
<!-- Hot paths, memory/CPU/latency implications, benchmarks if available. -->
### 🖼️ Screenshots / GIFs (if UI)
<!-- Before/After, dark mode, responsive states. -->
## ✅ Checklist
<!--
Review and check off items as you complete them.
-->
- [ ] Ive read the [contribution guidelines](CONTRIBUTING.md)
- [ ] Verified that changes behave as expected for all platforms
- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
- [ ] No secrets, hardcoded credentials, or private config files are included
- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
- [ ] Type checks pass (tsc/biome/etc.)
- [ ] Docs updated (README/ADR/usage/API)
- [ ] No secrets/credentials included; env vars documented
- [ ] Release notes/CHANGELOG entry added (if applicable)
- [ ] Verified locally that changes behave as expected
## 🔍 Testing Instructions
<!--
Describe how reviewers can test your changes. This will help the PR get merged faster.
Describe how reviewers can test your changes.
Example:
1. Open the settings page and scroll to the bottom
2. Verify that the clear data button is visible and pressable
3. Verify that when you click the clear data button, a dialog appears prompting you to confirm
4. Verify that when you click the confirm button, the data is cleared and a toast message is displayed
1. `git fetch origin pull/<PR_ID>/head:branchname && git checkout branchname`
2. Install deps: `npm|pnpm|yarn|bun install`
3. Start service/app: `npm|pnpm|yarn|bun run [target]` (e.g., `npm run ios` or `bun run android:tv`)
4. Run tests: `npm|pnpm|yarn|bun test`
5. Verification steps:
- [ ] Expected UI/endpoint behavior
- [ ] Logs show no errors
- [ ] Edge cases covered (list)
-->
## ⚙️ 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
View File

@@ -25,25 +25,6 @@
"osvVulnerabilityAlerts": true,
"configMigration": true,
"separateMinorPatch": true,
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": ["/\\.ya?ml$/"],
"matchStrings": [
"# renovate: datasource=(?<datasource>\\S+) depName=(?<depName>\\S+)(?: versioning=(?<versioning>\\S+))?\\s+xcode-version:\\s*[\"']?(?<currentValue>[^\"'\\s]+)"
],
"versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}loose{{/if}}"
}
],
"customDatasources": {
"xcode": {
"defaultRegistryUrlTemplate": "https://xcodereleases.com/data.json",
"format": "json",
"transformTemplates": [
"{ \"releases\": [$[version.release.release=true].{\"version\": version.number}] }"
]
}
},
"lockFileMaintenance": {
"vulnerabilityAlerts": {
"enabled": true,

View File

@@ -13,7 +13,7 @@ on:
jobs:
build-android-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
if: false # [tvos-test] temporarily disabled to only run tvOS builds
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (Phone)
permissions:
@@ -96,7 +96,7 @@ jobs:
retention-days: 7
build-android-tv:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
if: false # [tvos-test] temporarily disabled to only run tvOS builds
runs-on: ubuntu-24.04
name: 🤖 Build Android APK (TV)
permissions:
@@ -179,7 +179,7 @@ jobs:
retention-days: 7
build-ios-phone:
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
if: false # [tvos-test] temporarily disabled to only run tvOS builds
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone)
permissions:
@@ -218,8 +218,7 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.2"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
@@ -244,7 +243,7 @@ jobs:
retention-days: 7
build-ios-phone-unsigned:
if: (!contains(github.event.head_commit.message, '[skip ci]'))
if: false # [tvos-test] temporarily disabled to only run tvOS builds
runs-on: macos-26
name: 🍎 Build iOS IPA (Phone - Unsigned)
permissions:
@@ -283,8 +282,7 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:
@@ -304,7 +302,7 @@ jobs:
build-ios-tv:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
if: false && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
if: (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'))
runs-on: macos-26
name: 🍎 Build tvOS IPA
permissions:
@@ -343,8 +341,7 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.2"
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
@@ -369,9 +366,8 @@ jobs:
retention-days: 7
build-ios-tv-unsigned:
# Unsigned tvOS build is enabled (compiles without Apple credentials).
# The signed tvOS job above stays disabled until tvOS provisioning
# profiles are set up in EAS (app + TopShelf targets).
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
if: (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build tvOS IPA (Unsigned)
@@ -411,8 +407,7 @@ jobs:
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
# renovate: datasource=custom.xcode depName=xcode versioning=loose
xcode-version: "26.4"
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:

View File

@@ -1,132 +0,0 @@
name: 🚀 Release (EAS Build + Submit)
# Cloud EAS build + auto-submit for iOS, tvOS and Android on merge to main.
# A manual approval gate (the `production` GitHub Environment) pauses the run
# before any build/submit starts. Configure required reviewers on that
# environment in repo Settings → Environments → production.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches: [main]
workflow_dispatch:
jobs:
approve:
name: 🔐 Approve release
runs-on: ubuntu-24.04
environment: production
steps:
- name: ✅ Release approved
run: echo "Release approved for ${{ github.sha }}"
release:
name: 🚀 ${{ matrix.name }}
needs: approve
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: 🍎 iOS
platform: ios
profile: production
- name: 📺 tvOS
platform: ios
profile: production_tv
- name: 🤖 Android
platform: android
profile: production
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
submodules: recursive
show-progress: false
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache
- name: 📦 Install dependencies and reload submodules
run: |
bun install --frozen-lockfile
bun run submodule-reload
- name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
eas-cache: true
# tvOS uses local credentials (EAS can't manage tvOS provisioning
# remotely, including the TopShelf extension target). Restore the
# gitignored credentials.json + cert + profiles from secrets so the
# cloud build can sign with `credentialsSource: local`.
- name: 🔐 Restore tvOS signing credentials
if: matrix.profile == 'production_tv'
env:
EAS_CREDENTIALS_JSON: ${{ secrets.EAS_CREDENTIALS_JSON }}
TVOS_DIST_CERT_P12_BASE64: ${{ secrets.TVOS_DIST_CERT_P12_BASE64 }}
TVOS_APP_PROFILE_BASE64: ${{ secrets.TVOS_APP_PROFILE_BASE64 }}
TVOS_TOPSHELF_PROFILE_BASE64: ${{ secrets.TVOS_TOPSHELF_PROFILE_BASE64 }}
run: |
mkdir -p certs profiles
printf '%s' "$EAS_CREDENTIALS_JSON" > credentials.json
echo "$TVOS_DIST_CERT_P12_BASE64" | base64 -d > certs/distribution.p12
echo "$TVOS_APP_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_tvOS_App_Store.mobileprovision
echo "$TVOS_TOPSHELF_PROFILE_BASE64" | base64 -d > profiles/Streamyfin_TopShelf_tvOS_App_Store.mobileprovision
# iOS + tvOS submit upload to App Store Connect with an ASC API key.
# EAS reads it from EXPO_ASC_API_KEY_PATH / EXPO_ASC_KEY_ID /
# EXPO_ASC_ISSUER_ID (set on the build step below). Write the .p8,
# tolerating either raw-PEM or base64-encoded secret content.
- name: 🔐 Restore App Store Connect API key
if: matrix.platform == 'ios'
env:
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
run: |
if printf '%s' "$APPLE_KEY_CONTENT" | grep -q "BEGIN PRIVATE KEY"; then
printf '%s' "$APPLE_KEY_CONTENT" > "$RUNNER_TEMP/asc_api_key.p8"
else
printf '%s' "$APPLE_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/asc_api_key.p8"
fi
# Android submit needs a Google Play service account JSON. eas.json's
# submit.production.android.serviceAccountKeyPath points at this file.
- name: 🔐 Restore Google Play service account
if: matrix.platform == 'android'
env:
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
run: printf '%s' "$GOOGLE_SERVICE_ACCOUNT_KEY" > google-service-account.json
- name: 🚀 Build & submit (${{ matrix.name }})
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# Consumed by eas submit for iOS/tvOS; ignored for Android.
EXPO_ASC_API_KEY_PATH: ${{ runner.temp }}/asc_api_key.p8
EXPO_ASC_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
EXPO_ASC_ISSUER_ID: ${{ secrets.APPLE_KEY_ISSUER_ID }}
run: |
eas build \
--platform ${{ matrix.platform }} \
--profile ${{ matrix.profile }} \
--auto-submit \
--non-interactive

8
.gitignore vendored
View File

@@ -18,9 +18,6 @@ web-build/
/androidmobile
/androidtv
# Gradle caches (top-level + per-module native projects)
**/.gradle/
# Module-specific Builds
modules/mpv-player/android/build
modules/player/android
@@ -77,8 +74,3 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts
build/
.claude/
.agents/skills/**
skills-lock.json
# CI-injected Google Play service account key (written at build time)
google-service-account.json

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.54.1",
"version": "0.54.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -36,6 +36,7 @@
"appleTeamId": "MWD5K362T8"
},
"android": {
"versionCode": 93,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",
@@ -77,16 +78,15 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "16.4",
"useFrameworks": "static",
"forceStaticLinking": ["ExpoUI", "GlassEffectView", "GlassPoster"]
"deploymentTarget": "15.6",
"useFrameworks": "static"
},
"android": {
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"],
"compileSdkVersion": 36,
"targetSdkVersion": 35,
"buildToolsVersion": "35.0.0",
"kotlinVersion": "2.1.20",
"kotlinVersion": "2.0.21",
"minSdkVersion": 26,
"usesCleartextTraffic": true,
"packagingOptions": {
@@ -143,8 +143,8 @@
[
"./plugins/withGitPod.js",
{
"podName": "MPVKit",
"podspecUrl": "https://raw.githubusercontent.com/mpv-ios/MPVKit/0.41.0-av/MPVKit.podspec"
"podName": "MPVKit-GPL",
"podspecUrl": "https://raw.githubusercontent.com/streamyfin/MPVKit/0.40.0-av/MPVKit-GPL.podspec"
}
]
],

View File

@@ -16,7 +16,7 @@ export interface MenuLink {
icon: string;
}
export default function CustomLinksPage() {
export default function menuLinks() {
const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);

View File

@@ -5,7 +5,7 @@ import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function FavoritesPage() {
export default function favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false);

View File

@@ -9,7 +9,6 @@ import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
@@ -48,7 +47,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -59,7 +66,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -89,7 +104,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -100,7 +123,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -111,7 +142,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -122,7 +161,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -133,7 +180,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -144,7 +199,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -174,7 +237,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -185,7 +256,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -196,7 +275,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -207,7 +294,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -218,7 +313,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -229,7 +332,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
@@ -240,7 +351,15 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -250,7 +369,11 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
headerLeft: () => <HeaderBackButton />,
headerLeft: () => (
<Pressable onPress={() => _router.back()} className='pl-0.5'>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",

View File

@@ -1,3 +1,4 @@
import { BottomSheetModal } from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -6,7 +7,6 @@ import { Alert, Platform, ScrollView, View } from "react-native";
import { Pressable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ActiveDownloads from "@/components/downloads/ActiveDownloads";
@@ -18,20 +18,15 @@ import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { writeToLog } from "@/utils/log";
export default function DownloadsPage() {
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom);
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
@@ -106,7 +101,7 @@ export default function DownloadsPage() {
navigation.setOptions({
headerRight: () => (
<Pressable
onPress={() => bottomSheetModalRef.current?.present()}
onPress={bottomSheetModalRef.current?.present}
className='px-2'
>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
@@ -121,7 +116,7 @@ export default function DownloadsPage() {
}
}, [showMigration]);
const deleteMovies = () =>
const _deleteMovies = () =>
deleteFileByType("Movie")
.then(() =>
toast.success(
@@ -132,7 +127,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
const _deleteShows = () =>
deleteFileByType("Episode")
.then(() =>
toast.success(
@@ -143,7 +138,7 @@ export default function DownloadsPage() {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
});
const deleteOtherMedia = () =>
const _deleteOtherMedia = () =>
Promise.all(
otherMedia
.filter((item) => item.item.Type)
@@ -167,9 +162,6 @@ export default function DownloadsPage() {
),
);
const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows(), deleteOtherMedia()]);
return (
<OfflineModeProvider isOffline={true}>
<ScrollView
@@ -264,36 +256,6 @@ export default function DownloadsPage() {
)}
</View>
</ScrollView>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
enablePanDownToClose
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
{otherMedia.length > 0 && (
<Button color='purple' onPress={deleteOtherMedia}>
{t("home.downloads.delete_all_other_media_button")}
</Button>
)}
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</OfflineModeProvider>
);
}

View File

@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
export default function SessionsPage() {
export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();
@@ -72,7 +72,7 @@ const SessionCard = ({ session }: SessionCardProps) => {
};
const getProgressPercentage = () => {
if (!session.NowPlayingItem?.RunTimeTicks) {
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
return 0;
}

View File

@@ -59,17 +59,17 @@ function SettingsMobile() {
<QuickConnect className='mb-4' />
<View className='mb-4'>
<ListGroup title={t("pairing.pair_with_phone_title")}>
<ListItem
onPress={() =>
router.push("/(auth)/(tabs)/(home)/companion-login")
}
title={t("pairing.pair_with_phone")}
textColor='blue'
/>
</ListGroup>
</View>
<TouchableOpacity
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
>
<Text className='text-white font-bold text-base mb-1'>
{t("pairing.pair_with_phone_title")}
</Text>
<Text className='text-neutral-400 text-sm'>
{t("pairing.pair_with_phone_description")}
</Text>
</TouchableOpacity>
<View className='mb-4'>
<AppLanguageSelector />

View File

@@ -12,7 +12,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function AppearanceHideLibrariesPage() {
export default function page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -11,7 +11,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function HideLibrariesPage() {
export default function page() {
const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() {
export default function page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() {
export default function page() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();

View File

@@ -18,7 +18,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function MarlinSearchPage() {
export default function page() {
const navigation = useNavigation();
const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ import { ListItem } from "@/components/list/ListItem";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function StreamystatsPage() {
export default function page() {
const { t } = useTranslation();
const navigation = useNavigation();
const insets = useSafeAreaInsets();

View File

@@ -37,10 +37,8 @@ const Page: React.FC = () => {
ItemFields.MediaStreams,
]);
// Lazily preload item with full media sources in background — never cache
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
gcTime: 0,
});
// Lazily preload item with full media sources in background
const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {

View File

@@ -13,7 +13,7 @@ import {
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
export default function JellyseerrCompanyPage() {
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();

View File

@@ -9,7 +9,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
export default function JellyseerrGenrePage() {
export default function page() {
const local = useLocalSearchParams();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();

View File

@@ -1,4 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -24,12 +31,6 @@ import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
@@ -75,8 +76,8 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
data: details,
@@ -142,6 +143,17 @@ const MobilePage: React.FC = () => {
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
jellyseerrApi
@@ -466,7 +478,6 @@ const MobilePage: React.FC = () => {
// This is till it's fixed because the menu isn't selectable on TV
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
@@ -474,6 +485,8 @@ const MobilePage: React.FC = () => {
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss}
>
<BottomSheetView>

View File

@@ -11,7 +11,7 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function JellyseerrPersonPage() {
export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();

View File

@@ -1,13 +1,13 @@
import { Slot, Stack, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "expo-router/js-top-tabs";
} from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
} from "@react-navigation/native";
import { Slot, Stack, withLayoutContext } from "expo-router";
import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator();

View File

@@ -8,7 +8,7 @@ import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function LiveTvChannelsPage() {
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets();

View File

@@ -17,7 +17,7 @@ const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function LiveTvGuidePage() {
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
export default function LiveTvRecordingsPage() {
export default function page() {
const { t } = useTranslation();
return (
<View className='flex items-center justify-center h-full -mt-12'>

View File

@@ -40,7 +40,6 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -105,10 +104,6 @@ const Page = () => {
const { orientation } = useOrientation();
// Fallback refresh for newly added content when returning to the library
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();

View File

@@ -1,13 +1,13 @@
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "expo-router/js-top-tabs";
} from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
} from "@react-navigation/native";
import { Stack, useLocalSearchParams, withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
const { Navigator } = createMaterialTopTabNavigator();

View File

@@ -1,8 +1,8 @@
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,8 +1,8 @@
import { getArtistsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,9 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useNavigation, useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useNavigation, useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -1,9 +1,9 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useRoute } from "@react-navigation/native";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useRoute } from "expo-router/react-navigation";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

View File

@@ -66,7 +66,7 @@ const exampleSearches = [
"The Mandalorian",
];
export default function SearchPage() {
export default function search() {
const params = useLocalSearchParams();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -221,7 +221,7 @@ export default function SearchPage() {
const ids = response1.data.ids;
if (!ids?.length) {
if (!ids || !ids.length) {
return [];
}

View File

@@ -3,11 +3,11 @@ import {
type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation";
import { withLayoutContext } from "expo-router";
import type {
ParamListBase,
TabNavigationState,
} from "expo-router/react-navigation";
} from "@react-navigation/native";
import { withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";

View File

@@ -1,5 +1,6 @@
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
@@ -221,129 +222,133 @@ export default function NowPlayingScreen() {
if (!currentTrack) {
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
</BottomSheetModalProvider>
);
}
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
);
}
return (
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
<View className='flex-row'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
</BottomSheetModalProvider>
);
}

View File

@@ -63,7 +63,7 @@ import { writeToLog } from "@/utils/log";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native";
export default function DirectPlayerPage() {
export default function page() {
const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -185,11 +185,11 @@ export default function DirectPlayerPage() {
return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Initialize TV audio/subtitle indices from URL params.
// No undefined guard: when a new episode's URL omits audioIndex, reset to
// undefined (media default) rather than leaking the previous episode's track.
// Initialize TV audio/subtitle indices from URL params
useEffect(() => {
setCurrentAudioIndex(audioIndex);
if (audioIndex !== undefined) {
setCurrentAudioIndex(audioIndex);
}
}, [audioIndex]);
useEffect(() => {
@@ -317,7 +317,7 @@ export default function DirectPlayerPage() {
}
let result: Stream | null = null;
if (offline && downloadedItem?.mediaSource) {
if (offline && downloadedItem && downloadedItem.mediaSource) {
const url = downloadedItem.videoFilePath;
if (item) {
result = {
@@ -470,11 +470,8 @@ export default function DirectPlayerPage() {
return {
ItemId: item.Id,
// Report the live selection so server-side session/resume state reflects
// mid-playback track changes. Note: index 0 is valid (don't treat as
// falsy); -1 means "off" and is reported as-is.
AudioStreamIndex: currentAudioIndex,
SubtitleStreamIndex: currentSubtitleIndex,
AudioStreamIndex: audioIndex ? audioIndex : undefined,
SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
MediaSourceId: mediaSourceId,
PositionTicks: msToTicks(progress.get()),
IsPaused: !isPlaying,
@@ -488,8 +485,8 @@ export default function DirectPlayerPage() {
}, [
stream,
item?.Id,
currentAudioIndex,
currentSubtitleIndex,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
isPlaying,
@@ -556,8 +553,8 @@ export default function DirectPlayerPage() {
},
[
item?.Id,
currentAudioIndex,
currentSubtitleIndex,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
@@ -825,10 +822,12 @@ export default function DirectPlayerPage() {
],
);
/** PiP handler for MPV */
const _onPictureInPictureChange = useCallback(
(e: { nativeEvent: { isActive: boolean } }) => {
const { isActive } = e.nativeEvent;
setIsPipMode(isActive);
// Hide controls when entering PiP
if (isActive) {
_setShowControls(false);
}
@@ -846,9 +845,6 @@ export default function DirectPlayerPage() {
// Memoize video ref functions to prevent unnecessary re-renders
const startPictureInPicture = useCallback(async () => {
// Hide controls BEFORE entering PiP so the window captures a clean view
_setShowControls(false);
setIsPipMode(true);
return videoRef.current?.startPictureInPicture?.();
}, []);
@@ -1013,9 +1009,8 @@ export default function DirectPlayerPage() {
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
// Use the live selection, not the stale URL params (see goToNextItem).
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
},
source: stream?.mediaSource ?? undefined,
});
@@ -1034,8 +1029,8 @@ export default function DirectPlayerPage() {
}, [
previousItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
subtitleIndex,
audioIndex,
stream?.mediaSource,
bitrateValue,
router,
@@ -1080,10 +1075,8 @@ export default function DirectPlayerPage() {
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(nextItem, settings, {
indexes: {
// Use the live selection (updated when the user changes tracks
// mid-playback), not the stale URL params the episode started with.
subtitleIndex: currentSubtitleIndex,
audioIndex: currentAudioIndex,
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
},
source: stream?.mediaSource ?? undefined,
});
@@ -1102,8 +1095,8 @@ export default function DirectPlayerPage() {
}, [
nextItem,
settings,
currentSubtitleIndex,
currentAudioIndex,
subtitleIndex,
audioIndex,
stream?.mediaSource,
bitrateValue,
router,
@@ -1254,7 +1247,6 @@ export default function DirectPlayerPage() {
nowPlayingMetadata={nowPlayingMetadata}
onProgress={onProgress}
onPlaybackStateChange={onPlaybackStateChanged}
onPictureInPictureChange={_onPictureInPictureChange}
onLoad={() => setIsVideoLoaded(true)}
onError={(e: { nativeEvent: MpvOnErrorEventPayload }) => {
console.error("Video Error:", e.nativeEvent);

View File

@@ -1248,7 +1248,7 @@ const styles = StyleSheet.create({
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFill,
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: scaleSize(14),
justifyContent: "center",

View File

@@ -1,12 +1,13 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
@@ -394,9 +395,8 @@ function Layout() {
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
return (
query.state.status === "success" && query.options.gcTime !== 0
);
// Only persist successful queries
return query.state.status === "success";
},
},
}}
@@ -411,125 +411,127 @@ function Layout() {
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"files": {
"includes": [
"**/*",

View 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}) => `/**

View File

@@ -1,7 +1,10 @@
diff --git a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
diff --git a/node_modules/react-native-bottom-tabs/.bun-tag-b32ab1c60a5dfcf7 b/.bun-tag-b32ab1c60a5dfcf7
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/BottomAccessoryProvider.swift b/ios/BottomAccessoryProvider.swift
index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c19280df69 100644
--- a/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
+++ b/node_modules/react-native-bottom-tabs/ios/BottomAccessoryProvider.swift
--- a/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate
}
@@ -11,10 +14,10 @@ index 539efee7156599e1fc795e11bf411b7dfaf12ec7..b2af39a2e6b014e9b1ae0a51b21115c1
@available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none"
diff --git a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabView/NewTabView.swift
--- a/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
@@ -53,10 +56,10 @@ index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cf
}
#endif
+
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewImpl.swift
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder
@@ -66,10 +69,10 @@ index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d
if #available(iOS 26.0, macOS 26.0, *) {
if let behavior {
self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
+++ b/node_modules/react-native-bottom-tabs/ios/TabViewProps.swift
--- a/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp
case onScrollDown

View 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

View File

@@ -1,7 +1,10 @@
diff --git a/node_modules/react-native-udp/react-native-udp.podspec b/node_modules/react-native-udp/react-native-udp.podspec
diff --git a/node_modules/react-native-udp/.bun-tag-ea7df8754aa4db91 b/.bun-tag-ea7df8754aa4db91
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/react-native-udp.podspec b/react-native-udp.podspec
index 7450cc7d0862aadfb47d796929c801a3dc423a57..fa3e42c0152ef2d87536b8c2e484f64d525e35ec 100644
--- a/node_modules/react-native-udp/react-native-udp.podspec
+++ b/node_modules/react-native-udp/react-native-udp.podspec
--- a/react-native-udp.podspec
+++ b/react-native-udp.podspec
@@ -9,7 +9,8 @@ Pod::Spec.new do |s|
s.homepage = package_json["homepage"]
s.license = package_json["license"]

13967
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -6,11 +12,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/Colors";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import {
deleteAccountCredential,
type SavedServer,
@@ -38,7 +39,7 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const isAndroid = Platform.OS === "android";
const snapPoints = useMemo(
@@ -63,6 +64,17 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!server) return;
@@ -106,16 +118,15 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
);
if (!server) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
>
<BottomSheetView
style={{

View File

@@ -1,9 +1,14 @@
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { type Href } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
@@ -17,11 +22,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector";
@@ -89,7 +89,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user],
);
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
@@ -199,30 +199,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
);
}
const downloadDetailsPromises = items.map(async (item) => {
// Ensure the snapshot we store offline carries the Chapters array.
// Page-level fetches sometimes use a fields filter that omits it; the
// offline player would then render no chapter ticks / list.
let itemForDownload = item;
if (!itemForDownload.Chapters && itemForDownload.Id) {
try {
const enriched = await getUserLibraryApi(api).getItem({
itemId: itemForDownload.Id,
userId: user.Id!,
});
if (enriched.data) {
itemForDownload = enriched.data;
}
} catch (e) {
console.warn(
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
e,
);
}
}
const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1
? getDefaultPlaySettings(itemForDownload, settings!)
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
@@ -231,7 +210,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const downloadDetails = await getDownloadUrl({
api,
item: itemForDownload,
item,
userId: user.Id!,
mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1,
@@ -243,7 +222,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return {
url: downloadDetails?.url,
item: itemForDownload,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
@@ -316,6 +295,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const renderButtonContent = () => {
// For single item downloads, show progress if item is being processed
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
@@ -363,8 +353,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
};
if (Platform.isTV) return null;
return (
<View {...props}>
<RoundButton size={size} onPress={onButtonPress}>
@@ -380,7 +368,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose
android_keyboardInputMode='adjustResize'
keyboardBehavior='interactive'
keyboardBlurBehavior='restore'
>

View File

@@ -1,7 +1,10 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { BottomSheetModal } from "@/utils/expoUiBottomSheet";
/**
* GlobalModal Component
@@ -9,7 +12,8 @@ import { BottomSheetModal } from "@/utils/expoUiBottomSheet";
* This component renders a global bottom sheet modal that can be controlled
* from anywhere in the app using the useGlobalModal hook.
*
* Place this component at the root level of your app (in _layout.tsx).
* Place this component at the root level of your app (in _layout.tsx)
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
@@ -29,6 +33,17 @@ export const GlobalModal = () => {
[hideModal],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const defaultOptions = {
enableDynamicSizing: true,
enablePanDownToClose: true,
@@ -43,8 +58,6 @@ export const GlobalModal = () => {
// Merge default options with provided options
const modalOptions = { ...defaultOptions, ...modalState.options };
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={modalRef}
@@ -52,9 +65,12 @@ export const GlobalModal = () => {
? { snapPoints: modalOptions.snapPoints }
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
stackBehavior='push'
style={{ zIndex: 1000 }}
>
{modalState.content}

View File

@@ -1,4 +1,10 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Image } from "expo-image";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -7,11 +13,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { storage } from "@/utils/mmkv";
export interface IntroSheetRef {
@@ -20,7 +21,7 @@ export interface IntroSheetRef {
}
export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
const bottomSheetRef = useRef<BottomSheetMethods>(null);
const bottomSheetRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -35,6 +36,17 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
},
}));
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDismiss = useCallback(() => {
bottomSheetRef.current?.dismiss();
}, []);
@@ -44,13 +56,11 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
router.push("/settings");
}, []);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetRef}
enablePanDownToClose
enableDynamicSizing
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: "#171717" }}
handleIndicatorStyle={{ backgroundColor: "#737373" }}
>

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo,
MediaStream,
@@ -6,13 +12,8 @@ import type {
import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { formatBitrate } from "@/utils/bitrate";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
@@ -21,20 +22,9 @@ interface Props {
}
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
if (Platform.isTV) {
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>
);
}
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
@@ -46,27 +36,27 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
enablePanDownToClose
snapPoints={["80%"]}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4'>
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-bold'>{t("item_card.video")}</Text>
<TouchableOpacity
onPress={() => bottomSheetModalRef.current?.dismiss()}
hitSlop={12}
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>

View File

@@ -1,3 +1,9 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,11 +17,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { verifyAccountPIN } from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -42,7 +43,7 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [pinCode, setPinCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
@@ -77,6 +78,17 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const shake = () => {
Animated.sequence([
Animated.timing(shakeAnimation, {
@@ -147,18 +159,18 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
]);
};
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{

View File

@@ -1,15 +1,16 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -28,7 +29,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -61,6 +62,17 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSubmit = async () => {
if (!password) {
setError(t("password.enter_password"));
@@ -81,18 +93,18 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
}
};
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{

View File

@@ -1,4 +1,13 @@
import {
Button,
Host,
Menu,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
@@ -10,18 +19,6 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { BottomSheetScrollView } from "@/utils/expoUiBottomSheet";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module
// load and crashes the entire route tree on tvOS (expo-router requires every
// route file). Load it lazily and only off-TV; TV never renders these.
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { disabled } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
// Option types
export type RadioOption<T = any> = {
@@ -264,29 +261,25 @@ const PlatformDropdownComponent = ({
}
}, [isVisible, controlledOpen, controlledOnOpenChange]);
if (Platform.OS === "ios" && !Platform.isTV) {
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
// fills its parent and reports its own size via setStyleSize, so it can't
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
// height depends on the parent while the parent depends on the Host — a
// circular dependency that collapses to 0 for any selector nested more than
// one level deep (so only the first, shallowest dropdown stays visible).
// Giving the wrapper the measured size breaks the cycle; the Host then
// fills a concrete box.
if (Platform.OS === "ios") {
return (
<View style={triggerSize ?? { opacity: 0 }}>
{/* Hidden measurer: lays the trigger out off-flow to capture its
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
sizes to the trigger's content rather than to its parent. */}
<View
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
pointerEvents='none'
aria-hidden
onLayout={handleMeasureTrigger}
>
{trigger}
<View>
{/* Hidden measurer: lays the trigger out normally to capture its
intrinsic size, which we then pin onto the Host below. */}
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
<View
style={{ alignSelf: "flex-start" }}
onLayout={handleMeasureTrigger}
>
{trigger}
</View>
</View>
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
<Host
style={[
triggerSize ?? { opacity: 0 },
expoUIConfig?.hostStyle as any,
]}
>
<Menu label={trigger}>
{groups.flatMap((group, groupIndex) => {
// Check if this group has radio options
@@ -302,40 +295,41 @@ const PlatformDropdownComponent = ({
const items = [];
// Group radio options under a submenu ONLY if there's a title
// Add Picker for radio options ONLY if there's a group title
// Otherwise render as individual buttons
if (radioOptions.length > 0) {
if (group.title) {
// Use a nested Menu as a submenu for grouped options. This
// reads as "Title: Selected" and expands to the choices on
// tap, keeping the nested look while staying a dropdown.
// (Menu opens on a single tap and nests cleanly; ContextMenu
// would require a long-press and read as a context menu.)
const selectedOption = radioOptions.find(
// Use Picker for grouped options.
// Use the option index (a stable primitive) as the
// tag/selection value and React key. Option `value`s can be
// objects (e.g. bitrate / media source), which collapse to
// "[object Object]" as a key and never match the Picker's
// primitive selection.
const selectedRadioIndex = radioOptions.findIndex(
(opt) => opt.selected,
);
const displayTitle = selectedOption
? `${group.title}: ${selectedOption.label}`
: group.title;
items.push(
<Menu key={`submenu-${groupIndex}`} label={displayTitle}>
{radioOptions.map((option, optionIndex) => (
<Button
<Picker
key={`picker-${groupIndex}`}
label={group.title}
selection={
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
}
onSelectionChange={(index) => {
const selectedOption = radioOptions[index as number];
selectedOption?.onPress();
onOptionSelect?.(selectedOption?.value);
}}
>
{radioOptions.map((opt, optionIndex) => (
<SwiftUIText
key={`radio-${groupIndex}-${optionIndex}`}
label={option.label}
systemImage={
option.selected ? "checkmark.circle.fill" : "circle"
}
modifiers={
option.disabled ? [disabled(true)] : undefined
}
onPress={() => {
option.onPress();
onOptionSelect?.(option.value);
}}
/>
modifiers={[tag(optionIndex)]}
>
{opt.label}
</SwiftUIText>
))}
</Menu>,
</Picker>,
);
} else {
// Render radio options as direct buttons

View File

@@ -1,5 +1,6 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
@@ -31,7 +32,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { BottomSheetView } from "@/utils/expoUiBottomSheet";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -414,7 +414,7 @@ export const PlayButton: React.FC<Props> = ({
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

@@ -78,7 +78,7 @@ export const PlayButton: React.FC<Props> = ({
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

@@ -39,21 +39,19 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
if (Platform.OS === "ios") {
return (
<Pressable onPress={handlePress} {...(viewProps as any)}>
<BlurView
intensity={50}
tint='systemChromeMaterial'
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</BlurView>
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</Pressable>
);
}

View File

@@ -1,14 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { AccountSecurityType } from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -57,7 +58,7 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
const [pinCode, setPinCode] = useState("");
const [pinError, setPinError] = useState<string | null>(null);
@@ -92,6 +93,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
setPinError(null);
};
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleOptionSelect = (type: AccountSecurityType) => {
setSelectedType(type);
setPinCode("");
@@ -123,18 +135,18 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
return true;
};
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{

View File

@@ -1,196 +0,0 @@
/**
* A modal listing an item's chapters. Each row shows the chapter name and its
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
* chapter and closes the modal. Player-agnostic — the seek is injected.
*/
import { Ionicons } from "@expo/vector-icons";
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { memo, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import {
type ChapterEntry,
chapterStartsMs,
formatChapterTime,
sortedChapters,
} from "@/utils/chapters";
interface ChapterListProps {
visible: boolean;
chapters: ChapterInfo[] | null | undefined;
/** Current playback position in milliseconds (to highlight the row). */
currentPositionMs: number;
/** Seek the player to this millisecond position. */
onSeek: (positionMs: number) => void;
onClose: () => void;
}
const ROW_HEIGHT = 48;
function ChapterListComponent({
visible,
chapters,
currentPositionMs,
onSeek,
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
// every tick — chapters is the only input that drives the underlying array.
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
const activeIndex = useMemo(() => {
let idx = -1;
for (let i = 0; i < starts.length; i++) {
if (currentPositionMs >= starts[i]) idx = i;
else break;
}
return idx;
}, [currentPositionMs, starts]);
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
// children mounted across visible toggles, so subsequent opens never scroll.
// Trigger an imperative scroll each time the sheet becomes visible.
useEffect(() => {
if (!visible || activeIndex < 0 || entries.length === 0) return;
const raf = requestAnimationFrame(() => {
listRef.current?.scrollToIndex({
index: activeIndex,
animated: false,
viewPosition: 0.5,
});
});
return () => cancelAnimationFrame(raf);
}, [visible, activeIndex, entries.length]);
return (
<Modal
visible={visible}
transparent
animationType='slide'
onRequestClose={onClose}
>
<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,
},
});

View File

@@ -1,87 +0,0 @@
/**
* Chapter tick marks drawn as an absolute overlay over a progress slider.
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
* so the slider underneath still receives touches.
*/
import { memo, useState } from "react";
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
import type { ChapterMarker } from "@/utils/chapters";
interface ChapterTicksProps {
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
markers: ChapterMarker[];
/** Tick colour. */
color?: string;
/** Tick height in px — slightly less than the slider track thickness. */
height?: number;
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
width?: number;
}
function ChapterTicksComponent({
markers,
// Semi-transparent black contrasts against both the filled progress
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
// stay visible across the whole bar as playback advances.
color = "rgba(0,0,0,0.55)",
height = 14,
width = 2,
}: ChapterTicksProps) {
// Hooks must run unconditionally — keep them before any early return.
const [sliderWidth, setSliderWidth] = useState(0);
const handleLayout = (e: LayoutChangeEvent) => {
setSliderWidth(e.nativeEvent.layout.width);
};
// One chapter (typically a single marker at 0) is not worth marking.
if (markers.length <= 1) return null;
return (
<View
pointerEvents='none'
onLayout={handleLayout}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// Let ticks taller than this container bleed beyond its bounds.
overflow: "visible",
}}
>
{sliderWidth > 0 &&
markers
// Skip the leading 0ms marker — it overlaps the slider start and
// adds visual noise at an already-rendered boundary.
.filter((marker) => marker.positionMs > 0)
.map((marker, index) => {
// Align both the position AND the width onto the device's
// physical pixel grid. Without this, fractional dp values land
// at different sub-pixel fractions per tick — Android samples
// each one differently and some ticks render visibly thicker.
const centerDp = (marker.percent / 100) * sliderWidth;
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
const snappedWidth = PixelRatio.roundToNearestPixel(width);
return (
<View
key={`${marker.positionMs}-${index}`}
style={{
position: "absolute",
left,
top: "50%",
marginTop: -height / 2,
height,
width: snappedWidth,
backgroundColor: color,
}}
/>
);
})}
</View>
);
}
export const ChapterTicks = memo(ChapterTicksComponent);

View File

@@ -10,7 +10,6 @@ import {
type TextInputProps,
View,
} from "react-native";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface InputProps extends TextInputProps {
extraClassName?: string;
@@ -21,9 +20,6 @@ export function Input(props: InputProps) {
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
// TV-only: scales the input font with the tvTypographyScale setting.
// Not consumed by the mobile branch below.
const tvTypography = useScaledTVTypography();
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
@@ -45,18 +41,8 @@ export function Input(props: InputProps) {
};
if (Platform.isTV) {
// Scale the whole input (box height, padding, icon) proportionally with the
// font so the component grows/shrinks with the tvTypographyScale setting.
// Uses the `body` token (primary reading size); it resolves to 28 at Default.
const fontSize = tvTypography.body;
const factor = fontSize / 28;
const height = Math.round(56 * factor);
const paddingLeft = Math.round(24 * factor);
const iconSize = Math.round(26 * factor);
const iconMarginRight = Math.round(14 * factor);
const containerStyle = {
height,
height: 48,
borderRadius: 50,
borderWidth: isFocused ? 1.5 : 1,
borderColor: isFocused
@@ -65,16 +51,16 @@ export function Input(props: InputProps) {
overflow: "hidden" as const,
flexDirection: "row" as const,
alignItems: "center" as const,
paddingLeft,
paddingLeft: 16,
};
const inputElement = (
<>
<Ionicons
name='search'
size={iconSize}
size={20}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: iconMarginRight }}
style={{ marginRight: 12 }}
/>
<TextInput
ref={inputRef}
@@ -83,8 +69,8 @@ export function Input(props: InputProps) {
style={[
{
flex: 1,
height,
fontSize,
height: 48,
fontSize: 18,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",

View File

@@ -37,12 +37,11 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
}
/>
<View
style={
Platform.isTV
? { width: `${progress}%`, backgroundColor: "#ffffff" }
: { width: `${progress}%` }
}
className={`absolute bottom-0 left-0 h-1 ${Platform.isTV ? "" : "bg-purple-600"}`}
style={{
width: `${progress}%`,
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
}}
className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
/>
</>
);

View File

@@ -116,7 +116,7 @@ export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}, [process?.progress]);
// Return null after all hooks have been called
if (!process?.item?.Id) {
if (!process || !process.item || !process.item.Id) {
return null;
}

View File

@@ -1,10 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -12,11 +17,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button";
import { Input } from "../common/Input";
@@ -75,7 +75,7 @@ export const FilterSheet = <T,>({
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -143,15 +143,24 @@ export const FilterSheet = <T,>({
return data;
}, [search, filteredData, data]);
if (Platform.isTV) return null;
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -35,7 +35,6 @@ import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider";
@@ -90,10 +89,6 @@ const HomeMobile = () => {
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet();
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
// Show intro modal on first launch
useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");

View File

@@ -35,7 +35,6 @@ import { Loader } from "@/components/Loader";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import {
@@ -87,10 +86,6 @@ export const Home = () => {
const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal();
// Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event).
useRefreshLibraryOnFocus();
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@@ -351,7 +351,7 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
// Get subtitle for episodes
const episodeSubtitle = useMemo(() => {
if (activeItem?.Type !== "Episode") return null;
if (!activeItem || activeItem.Type !== "Episode") return null;
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
}, [activeItem]);

View File

@@ -1,3 +1,4 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react";
import {
type StyleProp,
@@ -7,7 +8,6 @@ import {
View,
type ViewStyle,
} from "react-native";
import { BottomSheetTextInput } from "@/utils/expoUiBottomSheet";
interface PinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {

View File

@@ -1,22 +1,23 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { writeDebugLog } from "@/utils/log";
@@ -33,7 +34,7 @@ interface Props {
}
const RequestModal = forwardRef<
BottomSheetMethods,
BottomSheetModalMethods,
Props & Omit<ViewProps, "id">
>(
(
@@ -282,13 +283,11 @@ const RequestModal = forwardRef<
defaultTags,
]);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={ref}
enablePanDownToClose
enableDynamicSizing
enableDismissOnClose
onDismiss={handleDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
@@ -296,6 +295,14 @@ const RequestModal = forwardRef<
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
stackBehavior='push'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>

View File

@@ -180,4 +180,4 @@ const styles = StyleSheet.create({
},
});
export { CARD_HEIGHT, CARD_WIDTH };
export { CARD_WIDTH, CARD_HEIGHT };

View File

@@ -155,7 +155,7 @@ export const TVLiveTVGuide: React.FC = () => {
);
// Fetch programs for visible channels
const { data: programsData } = useQuery({
const { data: programsData, isLoading: isLoadingPrograms } = useQuery({
queryKey: [
"livetv",
"tv-guide",

View File

@@ -382,18 +382,16 @@ export const Login: React.FC = () => {
</View>
</View>
</View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
</View>
) : (
<View className='flex flex-col flex-1 items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<View className='flex flex-col flex-1 w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
alignSelf: "center",
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
@@ -431,6 +429,8 @@ export const Login: React.FC = () => {
await handleConnect(server.address);
}}
/>
</View>
<View className='px-4 pb-2'>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);

View File

@@ -14,7 +14,6 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { scaleSize } from "@/utils/scaleSize";
interface TVPasswordEntryModalProps {
@@ -202,13 +201,6 @@ export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
setIsReady(false);
}, [visible]);
// Close the modal on the TV remote back/menu button while it is open.
useTVBackPress(() => {
if (!visible) return false;
onClose();
return true;
}, [visible, onClose]);
const handleSubmit = async () => {
if (!password) {
setError(t("password.enter_password"));

View File

@@ -1,3 +1,10 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, {
useCallback,
useEffect,
@@ -6,17 +13,11 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Keyboard, Platform } from "react-native";
import { ActivityIndicator, Keyboard } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface Props {
open: boolean;
@@ -31,7 +32,7 @@ export const CreatePlaylistModal: React.FC<Props> = ({
onPlaylistCreated,
initialTrackId,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const createPlaylist = useCreatePlaylist();
@@ -58,6 +59,17 @@ export const CreatePlaylistModal: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
@@ -74,15 +86,13 @@ export const CreatePlaylistModal: React.FC<Props> = ({
const isValid = name.trim().length > 0;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,23 +1,18 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface Props {
open: boolean;
@@ -30,7 +25,7 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
setOpen,
playlist,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -52,6 +47,17 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeletePlaylist = useCallback(() => {
if (!playlist?.Id) return;
@@ -83,15 +89,14 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
}, [playlist, deletePlaylist, setOpen, router, t]);
if (!playlist) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
@@ -14,7 +20,6 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -24,11 +29,6 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
interface Props {
open: boolean;
@@ -43,7 +43,7 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
trackToAdd,
onCreateNew,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
@@ -101,6 +101,17 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSelectPlaylist = useCallback(
async (playlist: BaseItemDto) => {
if (!trackToAdd?.Id || !playlist.Id) return;
@@ -131,15 +142,13 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
[api],
);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,14 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
type BottomSheetMethods,
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
} from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
export type PlaylistSortOption = "SortName" | "DateCreated";
@@ -42,7 +43,7 @@ export const PlaylistSortSheet: React.FC<Props> = ({
sortOrder,
onSortChange,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -62,6 +63,17 @@ export const PlaylistSortSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSortSelect = useCallback(
(option: PlaylistSortOption) => {
// If selecting same option, toggle order; otherwise use sensible default
@@ -81,15 +93,13 @@ export const PlaylistSortSheet: React.FC<Props> = ({
[sortBy, sortOrder, onSortChange, setOpen],
);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
@@ -12,7 +18,6 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -31,11 +36,6 @@ import {
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -56,7 +56,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
playlistId,
onRemoveFromPlaylist,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
@@ -128,6 +128,17 @@ export const TrackOptionsSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handlePlayNext = useCallback(() => {
if (track) {
playNext(track);
@@ -216,14 +227,13 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
if (!track) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,17 +1,15 @@
import {
Button,
ContextMenu,
Host,
Picker,
Text as SwiftUIText,
} from "@expo/ui/swift-ui";
import { buttonStyle, tag } from "@expo/ui/swift-ui/modifiers";
import { Platform, View } from "react-native";
import { FilterButton } from "@/components/filters/FilterButton";
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, Menu } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
interface DiscoverFiltersProps {
searchFilterId: string;
orderFilterId: string;
@@ -37,7 +35,7 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
setJellyseerrSortOrder,
t,
}) => {
if (Platform.OS === "ios" && !Platform.isTV) {
if (Platform.OS === "ios") {
return (
<Host
style={{
@@ -49,54 +47,42 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
marginLeft: "auto",
}}
>
<Menu
label={
<ContextMenu>
<ContextMenu.Trigger>
<Button
modifiers={[buttonStyle("glass")]}
systemImage='line.3.horizontal.decrease.circle'
/>
}
>
<Menu
label={`${t("library.filters.sort_by")}: ${t(
`home.settings.plugins.jellyseerr.order_by.${jellyseerrOrderBy}`,
)}`}
>
{sortOptions.map((item) => {
const isSelected =
jellyseerrOrderBy === (item as unknown as JellyseerrSearchSort);
return (
<Button
key={item}
label={t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() =>
setJellyseerrOrderBy(
item as unknown as JellyseerrSearchSort,
)
}
/>
);
})}
</Menu>
<Menu
label={`${t("library.filters.sort_order")}: ${t(
`library.filters.${jellyseerrSortOrder}`,
)}`}
>
{orderOptions.map((item) => {
const isSelected = jellyseerrSortOrder === item;
return (
<Button
key={item}
label={t(`library.filters.${item}`)}
systemImage={isSelected ? "checkmark.circle.fill" : "circle"}
onPress={() => setJellyseerrSortOrder(item)}
/>
);
})}
</Menu>
</Menu>
></Button>
</ContextMenu.Trigger>
<ContextMenu.Items>
<Picker
label={t("library.filters.sort_by")}
selection={jellyseerrOrderBy as unknown as string}
onSelectionChange={(value) => {
setJellyseerrOrderBy(value as unknown as JellyseerrSearchSort);
}}
>
{sortOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
</SwiftUIText>
))}
</Picker>
<Picker
label={t("library.filters.sort_order")}
selection={jellyseerrSortOrder}
onSelectionChange={(value) => {
setJellyseerrSortOrder(value as "asc" | "desc");
}}
>
{orderOptions.map((item) => (
<SwiftUIText key={item} modifiers={[tag(item)]}>
{t(`library.filters.${item}`)}
</SwiftUIText>
))}
</Picker>
</ContextMenu.Items>
</ContextMenu>
</Host>
);
}

View File

@@ -1,16 +1,9 @@
import { Button, Host } from "@expo/ui/swift-ui";
import { buttonStyle } from "@expo/ui/swift-ui/modifiers";
import { Platform, TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Tag } from "@/components/GenreTags";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import crashes the route tree on tvOS at module load.
// Load it lazily and only off-TV; TV never renders this component.
const { Button, Host, HStack, Spacer } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui"))
: require("@expo/ui/swift-ui");
const { buttonStyle } = Platform.isTV
? ({} as typeof import("@expo/ui/swift-ui/modifiers"))
: require("@expo/ui/swift-ui/modifiers");
type SearchType = "Library" | "Discover";
interface SearchTabButtonsProps {
@@ -24,10 +17,18 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
setSearchType,
t,
}) => {
if (Platform.OS === "ios" && !Platform.isTV) {
if (Platform.OS === "ios") {
return (
<Host style={{ height: 40, flex: 1 }}>
<HStack spacing={8}>
<>
<Host
style={{
height: 40,
width: 80,
flexDirection: "row",
gap: 10,
justifyContent: "space-between",
}}
>
<Button
modifiers={[
buttonStyle(
@@ -35,8 +36,19 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
),
]}
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
modifiers={[
buttonStyle(
@@ -44,11 +56,11 @@ export const SearchTabButtons: React.FC<SearchTabButtonsProps> = ({
),
]}
onPress={() => setSearchType("Discover")}
label={t("search.discover")}
/>
<Spacer />
</HStack>
</Host>
>
<Text>{t("search.discover")}</Text>
</Button>
</Host>
</>
);
}

View File

@@ -4,11 +4,10 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TvSearchView } from "@/modules/tv-search";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
@@ -17,46 +16,36 @@ import type {
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { scaleSize } from "@/utils/scaleSize";
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
import { TVSearchSection } from "./TVSearchSection";
import { TVSearchTabBadges } from "./TVSearchTabBadges";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Height of the native search bar itself. The tvOS grid keyboard presents as
// its own overlay when the field is focused, so we only reserve the bar height
// here — not the whole keyboard. Tunable once seen on device.
const SEARCH_AREA_HEIGHT = 250;
const SECTION_GAP = 10;
const SCALE_PADDING = 20;
// Loading skeleton for TV.
// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
// padding, heading typography, poster radius) so the placeholder lines up with
// the real content that replaces it.
// Loading skeleton for TV
const TVLoadingSkeleton: React.FC = () => {
const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const itemWidth = sizes.posters.poster;
const itemWidth = 210;
return (
<View style={{ overflow: "visible" }}>
{/* Section header placeholder — matches the heading typography + margins */}
<View
style={{
width: itemWidth,
height: typography.heading,
width: 200,
height: 28,
backgroundColor: "#262626",
borderRadius: 8,
marginBottom: 20,
marginLeft: sizes.padding.horizontal,
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
/>
<View
style={{
flexDirection: "row",
gap: sizes.gaps.item,
paddingLeft: sizes.padding.horizontal,
gap: 16,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
@@ -67,14 +56,15 @@ const TVLoadingSkeleton: React.FC = () => {
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: 10 / 15,
borderRadius: scaleSize(24),
marginBottom: scaleSize(8),
borderRadius: 12,
marginBottom: 8,
}}
/>
<View
style={{
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
@@ -134,6 +124,7 @@ interface TVSearchPageProps {
}
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
search,
setSearch,
debouncedSearch,
movies,
@@ -224,129 +215,125 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
return (
<View style={{ flex: 1 }}>
{/* Sticky header: search field stays pinned while results scroll below. */}
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Input */}
<View
style={{
paddingTop: insets.top + TOP_PADDING,
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING + 200,
}}
>
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
module). It renders the native search bar + grid keyboard and
forwards typed text into the existing query pipeline via setSearch;
our own results grid renders below. */}
<View
style={{
marginBottom: 24,
marginHorizontal: HORIZONTAL_PADDING,
height: SEARCH_AREA_HEIGHT,
}}
>
<TvSearchView
style={{ width: "100%", height: "100%" }}
placeholder={t("search.search")}
onChangeText={(e) => setSearch(e.nativeEvent.text)}
/>
</View>
<Input
placeholder={t("search.search")}
value={search}
onChangeText={setSearch}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
hasTVPreferredFocus={
debouncedSearch.length === 0 &&
sections.length === 0 &&
!showDiscover
}
/>
</View>
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
keyboardDismissMode='on-drag'
contentContainerStyle={{
paddingBottom: insets.bottom + 60,
}}
>
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
/>
</View>
)}
{/* Loading State */}
{currentLoading && (
<View style={{ gap: SECTION_GAP }}>
<TVLoadingSkeleton />
<TVLoadingSkeleton />
</View>
)}
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
)
? getImageUrl
: undefined
}
/>
))}
</View>
)}
{/* Jellyseerr/Discover Search Results */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
<TVJellyseerrSearchResults
movieResults={jellyseerrMovies}
tvResults={jellyseerrTv}
personResults={jellyseerrPersons}
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}
{/* Search Type Tab Badges */}
{showDiscover && (
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
<TVSearchTabBadges
searchType={searchType}
setSearchType={setSearchType}
showDiscover={showDiscover}
/>
)}
</View>
)}
{/* Discover Content (when no search query in Discover mode) */}
{isDiscoverMode &&
!jellyseerrLoading &&
debouncedSearch.length === 0 && (
<TVDiscover sliders={discoverSliders} />
)}
{/* Loading State */}
{currentLoading && (
<View style={{ gap: SECTION_GAP }}>
<TVLoadingSkeleton />
<TVLoadingSkeleton />
</View>
)}
{/* No Results State */}
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.6)",
}}
>
"{debouncedSearch}"
</Text>
</View>
)}
</ScrollView>
</View>
{/* Library Search Results */}
{isLibraryMode && !loading && (
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => (
<TVSearchSection
key={section.key}
title={section.title}
items={section.items!}
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,
)
? getImageUrl
: undefined
}
/>
))}
</View>
)}
{/* Jellyseerr/Discover Search Results */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
<TVJellyseerrSearchResults
movieResults={jellyseerrMovies}
tvResults={jellyseerrTv}
personResults={jellyseerrPersons}
loading={jellyseerrLoading}
noResults={jellyseerrNoResults}
searchQuery={debouncedSearch}
onMoviePress={onJellyseerrMoviePress || (() => {})}
onTvPress={onJellyseerrTvPress || (() => {})}
onPersonPress={onJellyseerrPersonPress || (() => {})}
/>
)}
{/* Discover Content (when no search query in Discover mode) */}
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
<TVDiscover sliders={discoverSliders} />
)}
{/* No Results State */}
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("search.no_results_found_for")}
</Text>
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.6)",
}}
>
"{debouncedSearch}"
</Text>
</View>
)}
</ScrollView>
);
};

View File

@@ -20,8 +20,6 @@ interface TVSearchSectionProps extends ViewProps {
onItemPress: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
/** Override the horizontal edge padding (defaults to the scaled TV padding). */
horizontalPadding?: number;
}
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
@@ -33,14 +31,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
onItemPress,
onItemLongPress,
imageUrlGetter,
horizontalPadding,
...props
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const sizes = useScaledTVSizes();
const ITEM_GAP = sizes.gaps.item;
const edgePadding = horizontalPadding ?? sizes.padding.horizontal;
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
@@ -277,7 +273,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 20,
marginLeft: edgePadding,
marginLeft: sizes.padding.horizontal,
letterSpacing: 0.5,
}}
>
@@ -298,10 +294,10 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentInset={{
left: edgePadding,
right: edgePadding,
left: sizes.padding.horizontal,
right: sizes.padding.horizontal,
}}
contentOffset={{ x: -edgePadding, y: 0 }}
contentOffset={{ x: -sizes.padding.horizontal, y: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
}}

View File

@@ -193,7 +193,7 @@ const TVSeasonButton: React.FC<{
>
<Text
style={{
fontSize: typography.callout,
fontSize: typography.body,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "bold",
}}
@@ -538,7 +538,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
/>
<Text
style={{
fontSize: typography.callout,
fontSize: typography.body,
fontWeight: "bold",
color: "#000000",
}}

View File

@@ -1,9 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -11,11 +16,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface LibraryOptions {
display: "row" | "list";
@@ -132,7 +132,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
updateSettings,
disabled = false,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -161,14 +161,25 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (disabled) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
@@ -176,6 +187,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetView>
<View

View File

@@ -1,3 +1,9 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import type React from "react";
@@ -6,11 +12,6 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput";
@@ -24,7 +25,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const snapPoints = useMemo(
@@ -35,6 +36,17 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try {
@@ -85,7 +97,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
handleIndicatorStyle={{
backgroundColor: "white",
@@ -93,8 +104,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>

View File

@@ -1,9 +1,14 @@
import { Stack } from "expo-router";
import type { ComponentProps } from "react";
import type { ParamListBase, RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import { Platform } from "react-native";
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 = {
title: "",

View File

@@ -63,7 +63,6 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
const typography = useScaledTVTypography();
const { t } = useTranslation();
const progress = useSharedValue(0);
const cancelled = useSharedValue(false);
const onFinishRef = useRef(onFinish);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
@@ -121,15 +120,13 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
return;
}
cancelled.value = false;
// Resume from current position
const remainingDuration = (1 - progress.value) * 8000;
progress.value = withTiming(
1,
{ duration: remainingDuration, easing: Easing.linear },
(finished) => {
if (finished && !cancelled.value) {
if (finished) {
runOnJS(onFinishRef.current)();
}
},
@@ -137,10 +134,9 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
// Cancel animation on unmount to prevent onFinish from firing after exit
return () => {
cancelled.value = true;
cancelAnimation(progress);
};
}, [show, isPlaying, progress, cancelled]);
}, [show, isPlaying, progress]);
const progressStyle = useAnimatedStyle(() => ({
width: `${progress.value * 100}%`,

View File

@@ -263,7 +263,7 @@ const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFill,
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: scaleSize(14),
justifyContent: "center",

View File

@@ -105,14 +105,14 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -1,34 +1,19 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
ChapterInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { type FC, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View } from "react-native";
import { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChapterList } from "@/components/chapters/ChapterList";
import { ChapterTicks } from "@/components/chapters/ChapterTicks";
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import { ChapterMarkers } from "./ChapterMarkers";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble";
// Chapter tick height in dp — matches the slider track height for a clean,
// flush look (no top/bottom overflow).
const TICK_HEIGHT = 10;
interface BottomControlsProps {
item: BaseItemDto;
/** Item chapters, used for the tick overlay and chapter list. */
chapters?: ChapterInfo[] | null;
/** Total media duration in milliseconds. */
durationMs: number;
showControls: boolean;
isSliding: boolean;
showRemoteBubble: boolean;
@@ -54,8 +39,6 @@ interface BottomControlsProps {
handleSliderChange: (value: number) => void;
handleTouchStart: () => void;
handleTouchEnd: () => void;
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
seekTo: (value: number) => void;
// Trickplay props
trickPlayUrl: {
@@ -82,8 +65,6 @@ interface BottomControlsProps {
export const BottomControls: FC<BottomControlsProps> = ({
item,
chapters,
durationMs,
showControls,
isSliding,
showRemoteBubble,
@@ -107,39 +88,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
handleSliderChange,
handleTouchStart,
handleTouchEnd,
seekTo,
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [chapterListVisible, setChapterListVisible] = useState(false);
// Only expose chapter UI when there are at least two real markers.
const chapterMarkerList = useMemo(
() => chapterMarkers(chapters, durationMs),
[chapters, durationMs],
);
const hasChapters = chapterMarkerList.length > 1;
// Current chapter name for the always-visible header label (live playback).
const currentChapterName = useMemo(
() => (hasChapters ? chapterNameAt(currentTime, chapters) : null),
[hasChapters, currentTime, chapters],
);
// Chapter name at the scrubbed position for the trickplay bubble. `time` is
// an {h,m,s} object derived from the slider's dragged value — convert back
// to ms for the lookup. Only useful while actively scrubbing.
const scrubChapterName = useMemo(() => {
if (!hasChapters) return null;
const scrubMs =
(time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000;
return chapterNameAt(scrubMs, chapters);
}, [hasChapters, time.hours, time.minutes, time.seconds, chapters]);
return (
<View
@@ -181,24 +136,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
{item?.Type === "Audio" && (
<Text className='text-xs opacity-50'>{item?.Album}</Text>
)}
{currentChapterName ? (
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
{currentChapterName}
</Text>
) : null}
</View>
<View className='flex flex-row 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>
)}
<View className='flex flex-row space-x-2 shrink-0'>
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
@@ -242,9 +181,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
height: 10,
justifyContent: "center",
alignItems: "stretch",
// Allow chapter ticks taller than the 10px track to bleed out
// top/bottom (RN defaults to overflow: "hidden" on Android).
overflow: "visible",
position: "relative",
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
@@ -272,7 +209,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
chapterName={scrubChapterName}
/>
)
}
@@ -282,7 +218,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min}
maximumValue={max}
/>
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
<ChapterMarkers chapterPositions={chapterPositions} />
</View>
<TimeDisplay
currentTime={currentTime}
@@ -290,13 +226,6 @@ export const BottomControls: FC<BottomControlsProps> = ({
/>
</View>
</View>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={currentTime}
onSeek={seekTo}
onClose={() => setChapterListVisible(false)}
/>
</View>
);
};

View File

@@ -88,14 +88,14 @@ const BrightnessSlider = () => {
maximumValue={max}
thumbWidth={0}
onValueChange={handleValueChange}
renderBubble={() => null}
renderThumb={() => null}
containerStyle={{
borderRadius: 50,
}}
theme={{
minimumTrackTintColor: "#FDFDFD",
maximumTrackTintColor: "#5A5A5A",
bubbleBackgroundColor: "transparent", // Hide the value bubble
bubbleTextColor: "transparent", // Hide the value text
}}
/>
<Ionicons

View File

@@ -267,7 +267,6 @@ export const Controls: FC<Props> = ({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
seekTo,
} = useVideoSlider({
progress,
isSeeking,
@@ -556,8 +555,6 @@ export const Controls: FC<Props> = ({
>
<BottomControls
item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
@@ -581,7 +578,6 @@ export const Controls: FC<Props> = ({
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}

View File

@@ -59,7 +59,6 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { TrickplayBubble } from "./TrickplayBubble";
import type { Track } from "./types";
import { useControlsTimeout } from "./useControlsTimeout";
interface Props {
@@ -233,8 +232,14 @@ export const Controls: FC<Props> = ({
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
const router = useRouter();
const { bitrateValue } = useLocalSearchParams<{
const {
bitrateValue,
subtitleIndex: paramSubtitleIndex,
audioIndex: paramAudioIndex,
} = useLocalSearchParams<{
bitrateValue: string;
subtitleIndex: string;
audioIndex: string;
}>();
const { nextItem: internalNextItem } = usePlaybackManager({
@@ -310,31 +315,6 @@ export const Controls: FC<Props> = ({
[onSubtitleIndexChange],
);
// Re-fetch subtitle streams from the server (e.g. after a server-side
// download) and map them to the modal's Track shape. setTrack drives the
// player through the same handler used for manual subtitle selection.
const refreshSubtitleTracks = useCallback(async (): Promise<Track[]> => {
try {
const streams = (await onRefreshSubtitleTracks?.()) ?? [];
// Skip streams without a real index: `?? -1` would alias them to the
// "disable subtitles" sentinel and mis-route selection.
return streams
.filter((stream) => typeof stream.Index === "number")
.map((stream) => {
const index = stream.Index as number;
return {
name:
stream.DisplayTitle ||
`${stream.Language || "Unknown"} (${stream.Codec})`,
index,
setTrack: () => onSubtitleIndexChange?.(index),
};
});
} catch {
return [];
}
}, [onRefreshSubtitleTracks, onSubtitleIndexChange]);
const {
trickPlayUrl,
calculateTrickplayUrl,
@@ -511,8 +491,6 @@ export const Controls: FC<Props> = ({
const goToNextItemRef = useRef<(opts?: { isAutoPlay?: boolean }) => void>(
() => {},
);
const exitingRef = useRef(false);
const [isExiting, setIsExiting] = useState(false);
const updateSeekBubbleTime = useCallback((ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
@@ -577,22 +555,10 @@ export const Controls: FC<Props> = ({
const handleOpenSubtitleSheet = useCallback(() => {
setLastOpenedModal("subtitle");
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option.
// Wrap each setTrack so selecting a subtitle ALSO updates the player's live
// index via onSubtitleIndexChange. The modal is a separate route, so the
// VideoContext router.setParams inside setTrack targets the modal — not the
// player — leaving currentSubtitleIndex stale. Without this sync, the next
// episode carries the previously-shown subtitle instead of the one the user
// just picked. (The audio sheet already uses onAudioIndexChange directly.)
const tracksWithoutDisable = (videoContextSubtitleTracks ?? [])
.filter((track) => track.index !== -1)
.map((track) => ({
...track,
setTrack: () => {
track.setTrack();
onSubtitleIndexChange?.(track.index);
},
}));
// Filter out the "Disable" option from VideoContext tracks since the modal adds its own "None" option
const tracksWithoutDisable = (videoContextSubtitleTracks ?? []).filter(
(track) => track.index !== -1,
);
showSubtitleModal({
item,
mediaSourceId: mediaSource?.Id,
@@ -604,12 +570,8 @@ export const Controls: FC<Props> = ({
(t) => t.index === -1,
);
disableTrack?.setTrack();
onSubtitleIndexChange?.(-1);
},
onLocalSubtitleDownloaded: handleLocalSubtitleDownloaded,
refreshSubtitleTracks: onRefreshSubtitleTracks
? refreshSubtitleTracks
: undefined,
});
controlsInteractionRef.current();
}, [
@@ -618,10 +580,7 @@ export const Controls: FC<Props> = ({
mediaSource?.Id,
videoContextSubtitleTracks,
subtitleIndex,
onSubtitleIndexChange,
handleLocalSubtitleDownloaded,
onRefreshSubtitleTracks,
refreshSubtitleTracks,
]);
const handleToggleTechnicalInfo = useCallback(() => {
@@ -970,16 +929,6 @@ export const Controls: FC<Props> = ({
router.back();
}, [router]);
const handleWillExit = useCallback(() => {
exitingRef.current = true;
setIsExiting(true);
}, []);
const handleCancelExit = useCallback(() => {
exitingRef.current = false;
setIsExiting(false);
}, []);
const { isSliding: isRemoteSliding } = useRemoteControl({
showControls: showControls,
toggleControls,
@@ -996,8 +945,6 @@ export const Controls: FC<Props> = ({
onVerticalDpad: handleVerticalDpad,
onHideControls: hideControls,
onBack: handleBack,
onWillExit: handleWillExit,
onCancelExit: handleCancelExit,
videoTitle: item?.Name ?? undefined,
});
@@ -1039,12 +986,13 @@ export const Controls: FC<Props> = ({
return;
}
// Use the live selection passed down from the player (currentSubtitleIndex
// / currentAudioIndex), not the stale URL params the episode started with.
// This path runs on autoplay; the manual "Next" button uses goToNextItemProp.
const previousIndexes = {
subtitleIndex,
audioIndex,
subtitleIndex: paramSubtitleIndex
? Number.parseInt(paramSubtitleIndex, 10)
: undefined,
audioIndex: paramAudioIndex
? Number.parseInt(paramAudioIndex, 10)
: undefined,
};
const {
@@ -1071,8 +1019,8 @@ export const Controls: FC<Props> = ({
[
nextItem,
settings,
subtitleIndex,
audioIndex,
paramSubtitleIndex,
paramAudioIndex,
mediaSource,
bitrateValue,
router,
@@ -1082,7 +1030,6 @@ export const Controls: FC<Props> = ({
goToNextItemRef.current = goToNextItem;
const handleAutoPlayFinish = useCallback(() => {
if (exitingRef.current) return;
goToNextItem({ isAutoPlay: true });
}, [goToNextItem]);
@@ -1157,7 +1104,7 @@ export const Controls: FC<Props> = ({
nextItem={nextItem}
api={api}
show={isCountdownActive}
isPlaying={isPlaying && !isExiting}
isPlaying={isPlaying}
onFinish={handleAutoPlayFinish}
onPlayNext={handleNextItemButton}
controlsVisible={showControls}
@@ -1461,14 +1408,14 @@ export const Controls: FC<Props> = ({
const styles = StyleSheet.create({
controlsContainer: {
...StyleSheet.absoluteFill,
...StyleSheet.absoluteFillObject,
},
darkOverlay: {
...StyleSheet.absoluteFill,
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
focusStealingOverlay: {
...StyleSheet.absoluteFill,
...StyleSheet.absoluteFillObject,
zIndex: 1,
},
bottomContainer: {

View File

@@ -1,5 +1,4 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { formatTimeString } from "@/utils/time";
@@ -17,8 +16,6 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
currentTime,
remainingTime,
}) => {
const { t } = useTranslation();
const getFinishTime = () => {
const now = new Date();
// remainingTime is in ms
@@ -40,7 +37,7 @@ export const TimeDisplay: FC<TimeDisplayProps> = ({
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text className='text-[10px] text-neutral-500 opacity-70'>
{t("player.ends_at", { time: getFinishTime() })}
ends at {getFinishTime()}
</Text>
</View>
</View>

View File

@@ -4,9 +4,7 @@ import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { CONTROLS_CONSTANTS } from "./constants";
// 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 BASE_IMAGE_SCALE = 1.4;
const BUBBLE_LEFT_OFFSET = 62;
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
@@ -30,8 +28,12 @@ interface TrickplayBubbleProps {
};
/** Scale factor for the image (default 1). Does not affect timestamp text. */
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> = ({
@@ -39,7 +41,6 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
trickplayInfo,
time,
imageScale = 1,
chapterName,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -48,28 +49,19 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`;
const finalScale = BASE_IMAGE_SCALE * imageScale;
return (
<View
style={{
position: "absolute",
// Sit just above the slider — high enough not to overlap the
// progress bar, low enough to feel anchored to the thumb.
left: -BUBBLE_LEFT_OFFSET * imageScale,
bottom: 0,
paddingTop: 12,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
justifyContent: "center",
alignItems: "center",
// Bring the bubble in front of the player title / overlays.
zIndex: 999,
elevation: 10,
}}
>
<View
@@ -97,51 +89,10 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
source={{ uri: url }}
contentFit='cover'
/>
{/*
* Bottom-right overlay (Jellyfin web style) — chapter name (small,
* faded) above the timestamp (small, bold). Sits on top of the
* trickplay frame inside the same overflow:hidden container so it
* always stays within the bubble bounds.
*/}
<View
pointerEvents='none'
style={{
position: "absolute",
left: 4,
bottom: 3,
alignItems: "flex-start",
paddingHorizontal: 3,
paddingVertical: 1,
borderRadius: 3,
backgroundColor: "rgba(0,0,0,0.55)",
maxWidth: tileWidth - 8,
}}
>
{chapterName ? (
<Text
numberOfLines={1}
style={{
color: "#fff",
fontSize: 7,
opacity: 0.85,
lineHeight: 9,
}}
>
{chapterName}
</Text>
) : null}
<Text
style={{
color: "#fff",
fontSize: 8,
fontWeight: "600",
lineHeight: 10,
}}
>
{timeStr}
</Text>
</View>
</View>
<Text style={{ marginTop: 30, fontSize: 16 }}>
{formatTime(time.hours, time.minutes, time.seconds)}
</Text>
</View>
);
};

View File

@@ -35,10 +35,6 @@ interface UseRemoteControlProps {
onLongSeekStop?: () => void;
/** Callback when up/down D-pad pressed (to show controls with play button focused) */
onVerticalDpad?: () => void;
/** Called before the exit confirmation Alert is shown (e.g., to pause countdown) */
onWillExit?: () => void;
/** Called when the user cancels the exit confirmation Alert */
onCancelExit?: () => void;
// Legacy props - kept for backwards compatibility with mobile Controls.tsx
// These are ignored in the simplified implementation
progress?: SharedValue<number>;
@@ -76,8 +72,6 @@ export function useRemoteControl({
onLongSeekRightStart,
onLongSeekStop,
onVerticalDpad,
onWillExit,
onCancelExit,
}: UseRemoteControlProps) {
// Keep these for backward compatibility with the component
const remoteScrubProgress = useSharedValue<number | null>(null);
@@ -91,24 +85,13 @@ export function useRemoteControl({
const onHideControlsRef = useRef(onHideControls);
const onBackRef = useRef(onBack);
const videoTitleRef = useRef(videoTitle);
const onWillExitRef = useRef(onWillExit);
const onCancelExitRef = useRef(onCancelExit);
useEffect(() => {
showControlsRef.current = showControls;
onHideControlsRef.current = onHideControls;
onBackRef.current = onBack;
videoTitleRef.current = videoTitle;
onWillExitRef.current = onWillExit;
onCancelExitRef.current = onCancelExit;
}, [
showControls,
onHideControls,
onBack,
videoTitle,
onWillExit,
onCancelExit,
]);
}, [showControls, onHideControls, onBack, videoTitle]);
// BackHandler owns player exit: Android TV sends hardware back here, and
// react-native-tvos maps the Apple TV menu button to the same API.
@@ -119,9 +102,6 @@ export function useRemoteControl({
return true;
}
if (onBackRef.current) {
// Signal Controls that exit is imminent (pauses countdown, sets guard)
onWillExitRef.current?.();
// Controls are hidden, so confirm before leaving playback.
Alert.alert(
"Stop Playback",
@@ -129,11 +109,7 @@ export function useRemoteControl({
? `Stop playing "${videoTitleRef.current}"?`
: "Are you sure you want to stop playback?",
[
{
text: "Cancel",
style: "cancel",
onPress: () => onCancelExitRef.current?.(),
},
{ text: "Cancel", style: "cancel" },
{ text: "Stop", style: "destructive", onPress: onBackRef.current },
],
);

View File

@@ -74,21 +74,6 @@ export function useVideoSlider({
[seek, play, progress, isSeeking],
);
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
// during a real slide and would carry stale state on a tap-to-seek.
const seekTo = useCallback(
(value: number) => {
const seekValue = Math.max(0, Math.floor(value));
progress.value = seekValue;
seek(seekValue);
if (isPlaying) {
play();
}
},
[seek, play, progress, isPlaying],
);
const handleSliderChange = useCallback(
debounce((value: number) => {
// Convert ms to ticks for trickplay
@@ -111,6 +96,5 @@ export function useVideoSlider({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
seekTo,
};
}

View File

@@ -28,4 +28,4 @@ type Track = {
localPath?: string;
};
export type { EmbeddedSubtitle, ExternalSubtitle, Track, TranscodedSubtitle };
export type { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle, Track };

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {
forwardRef,
@@ -10,7 +16,6 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -26,11 +31,6 @@ import {
useItemInWatchlists,
useMyWatchlistsQuery,
} from "@/hooks/useWatchlists";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
export interface WatchlistSheetRef {
@@ -263,7 +263,7 @@ const WatchlistSheetContent: React.FC<WatchlistSheetContentProps> = ({
export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
(_props, ref) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [currentItem, setCurrentItem] = React.useState<BaseItemDto | null>(
null,
);
@@ -283,13 +283,23 @@ export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
bottomSheetModalRef.current?.dismiss();
}, []);
if (Platform.isTV) return null;
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing
maxDynamicContentSize={600}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -49,8 +49,8 @@ export const TVGaps = {
* Base padding values in pixels.
*/
export const TVPadding = {
/** Horizontal padding from screen edges (static — matches native search inset) */
horizontal: 80,
/** Horizontal padding from screen edges */
horizontal: 90,
/** Padding to accommodate scale animations (1.05x) */
scale: 20,
@@ -142,9 +142,7 @@ export const useScaledTVSizes = (): ScaledTVSizes => {
large: Math.round(scaleSize(TVGaps.large) * scale),
},
padding: {
// Static: matches the native tvOS search bar inset, which is a fixed
// point value and does not change with the typography scale setting.
horizontal: TVPadding.horizontal,
horizontal: Math.round(scaleSize(TVPadding.horizontal) * scale),
scale: Math.round(scaleSize(TVPadding.scale) * scale),
vertical: Math.round(scaleSize(TVPadding.vertical) * scale),
heroHeight: TVPadding.heroHeight * scale,

View File

@@ -1,7 +1,6 @@
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "remote"
"version": ">= 9.1.0"
},
"build": {
"development": {
@@ -52,26 +51,23 @@
}
},
"production": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"channel": "0.54.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"channel": "0.54.0",
"android": {
"buildType": "apk",
"image": "latest"
}
},
"production-apk-tv": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"channel": "0.54.0",
"android": {
"buildType": "apk",
"image": "latest"
@@ -81,9 +77,8 @@
}
},
"production_tv": {
"bun": "1.3.5",
"environment": "production",
"autoIncrement": true,
"channel": "0.54.0",
"env": {
"EXPO_TV": "1"
},
@@ -93,22 +88,7 @@
}
},
"submit": {
"production": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
},
"android": {
"serviceAccountKeyPath": "./google-service-account.json",
"track": "internal",
"releaseStatus": "completed"
}
},
"production_tv": {
"ios": {
"appleTeamId": "MWD5K362T8",
"ascAppId": "6593660679"
}
}
"production": {},
"production_tv": {}
}
}

Some files were not shown because too many files have changed in this diff Show More