Compare commits

..

2 Commits

Author SHA1 Message Date
Uruk
aadb5f9f6d refactor(app): rename default function components
Renames default function components to PascalCase for consistency.

This change improves code readability and adheres to React component naming conventions.
2026-02-24 14:58:45 +01:00
Uruk
bc3e8dd9ed chore: update biome to v2.4.4
updates the biome linter and formatter to the latest version

this ensures that the project benefits from the newest features
and bug fixes in biome.
2026-02-24 14:58:30 +01:00
363 changed files with 4648 additions and 39579 deletions

View File

@@ -1,103 +0,0 @@
---
name: tv-validator
description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements.
tools: Read, Glob, Grep
model: haiku
color: blue
---
You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations.
## Critical Rules to Check
### 1. No .tv.tsx File Suffix
The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it.
**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution
**Correct**: Use `Platform.isTV` conditional rendering in the main file:
```typescript
if (Platform.isTV) {
return <TVMyComponent />;
}
return <MyComponent />;
```
### 2. No FlashList on TV
FlashList has focus issues on TV. Use FlatList instead.
**Violation**: `<FlashList` in TV code paths
**Correct**:
```typescript
{Platform.isTV ? (
<FlatList removeClippedSubviews={false} ... />
) : (
<FlashList ... />
)}
```
### 3. Modal Pattern
Never use overlay/absolute-positioned modals on TV. They break back button handling.
**Violation**: `position: "absolute"` or `Modal` component for TV overlays
**Correct**: Use navigation-based pattern:
- Create Jotai atom for state
- Hook that sets atom and calls `router.push()`
- Page in `app/(auth)/` that reads atom
- `Stack.Screen` with `presentation: "transparentModal"`
### 4. Typography
All TV text must use `TVTypography` component.
**Violation**: Raw `<Text>` in TV components
**Correct**: `<TVTypography variant="title">...</TVTypography>`
### 5. No Purple Accent Colors
TV uses white for focus states, not purple.
**Violation**: Purple/violet colors in TV focused states
**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds
### 6. Focus Handling
- Only ONE element should have `hasTVPreferredFocus={true}`
- Focusable items need `disabled={isModalOpen}` when overlays are visible
- Use `onFocus`/`onBlur` with scale animations
- Add padding for scale animations (focus scale clips without it)
### 7. List Configuration
TV lists need:
- `removeClippedSubviews={false}`
- `overflow: "visible"` on containers
- Sufficient padding for focus scale animations
### 8. Horizontal Padding
Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20).
### 9. Focus Guide Navigation
For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop.
Use `useState` for refs (not `useRef`) to trigger re-renders.
## Review Process
1. Read the file(s) to review
2. Check each rule above
3. Report violations with:
- Line number
- What's wrong
- How to fix it
4. If no violations, confirm the code follows TV patterns
## Output Format
```
## TV Validation Results
### ✓ Passes
- [List of rules that pass]
### ✗ Violations
- **[Rule Name]** (line X): [Description]
Fix: [How to correct it]
### Recommendations
- [Optional suggestions for improvement]
```

View File

@@ -12,59 +12,26 @@ Analyze the current conversation to extract useful facts that should be remember
## Instructions ## Instructions
1. Read the Learned Facts Index section in `CLAUDE.md` and scan existing files in `.claude/learned-facts/` to understand what's already recorded 1. Read the existing facts file at `.claude/learned-facts.md`
2. Review this conversation for learnings worth preserving 2. Review this conversation for learnings worth preserving
3. For each new fact: 3. For each new fact:
- Create a new file in `.claude/learned-facts/[kebab-case-name].md` using the template below - Write it concisely (1-2 sentences max)
- Append a new entry to the appropriate category in the **Learned Facts Index** section of `CLAUDE.md` - Include context for why it matters
- Add today's date
4. Skip facts that duplicate existing entries 4. Skip facts that duplicate existing entries
5. If a new category is needed, add it to the index in `CLAUDE.md` 5. Append new facts to `.claude/learned-facts.md`
## Fact File Template ## Fact Format
Create each file at `.claude/learned-facts/[kebab-case-name].md`: Use this format for each fact:
```
```markdown - **[Brief Topic]**: [Concise description of the fact] _(YYYY-MM-DD)_
# [Title]
**Date**: YYYY-MM-DD
**Category**: navigation | tv | native-modules | state-management | ui
**Key files**: `relevant/paths.ts`
## Detail
[Full description of the fact, including context for why it matters]
``` ```
## Index Entry Format ## Example Facts
Append to the appropriate category in the Learned Facts Index section of `CLAUDE.md`: - **State management**: Use Jotai atoms for global state, NOT React Context - atoms are in `utils/atoms/` _(2025-01-09)_
- **Package manager**: Always use `bun`, never npm or yarn - the project is configured for bun only _(2025-01-09)_
- **TV platform**: Check `Platform.isTV` for TV-specific code paths, not just OS checks _(2025-01-09)_
``` After updating the file, summarize what facts you added (or note if nothing new was learned this session).
- `kebab-case-name` | Brief one-line summary of the fact
```
Categories: Navigation, UI/Headers, State/Data, Native Modules, TV Platform
## Example
File `.claude/learned-facts/state-management-pattern.md`:
```markdown
# State Management Pattern
**Date**: 2025-01-09
**Category**: state-management
**Key files**: `utils/atoms/`
## Detail
Use Jotai atoms for global state, NOT React Context. Atoms are defined in `utils/atoms/`.
```
Index entry in `CLAUDE.md`:
```
State/Data:
- `state-management-pattern` | Use Jotai atoms for global state, not React Context
```
After updating, summarize what facts you added (or note if nothing new was learned this session).

View File

@@ -1,11 +1,8 @@
# Learned Facts (DEPRECATED) # Learned Facts
> **DEPRECATED**: This file has been replaced by individual fact files in `.claude/learned-facts/`. This file contains facts about the codebase learned from past sessions. These are things Claude got wrong or needed clarification on, stored here to prevent the same mistakes in future sessions.
> The compressed index is now inline in `CLAUDE.md` under "Learned Facts Index".
> New facts should be added as individual files using the `/reflect` command.
> This file is kept for reference only and is no longer auto-imported.
This file previously contained facts about the codebase learned from past sessions. This file is auto-imported into CLAUDE.md and loaded at the start of each session.
## Facts ## Facts
@@ -27,22 +24,4 @@ This file previously contained facts about the codebase learned from past sessio
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_ - **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_ - **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
- **Platform-specific file suffix (.tv.tsx) does NOT work**: The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render. _(2026-01-26)_

View File

@@ -1,9 +0,0 @@
# Header Button Locations
**Date**: 2026-01-10
**Category**: ui
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`, `components/common/HeaderBackButton.tsx`, `components/Chromecast.tsx`, `components/RoundButton.tsx`, `components/home/Home.tsx`, `app/(auth)/(tabs)/(home)/downloads/index.tsx`
## Detail
Header buttons are defined in multiple places: `app/(auth)/(tabs)/(home)/_layout.tsx` (SettingsButton, SessionsButton, back buttons), `components/common/HeaderBackButton.tsx` (reusable), `components/Chromecast.tsx`, `components/RoundButton.tsx`, and dynamically via `navigation.setOptions()` in `components/home/Home.tsx` and `app/(auth)/(tabs)/(home)/downloads/index.tsx`.

View File

@@ -1,9 +0,0 @@
# Intro Modal Trigger Location
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `components/home/Home.tsx`, `app/(auth)/(tabs)/_layout.tsx`
## Detail
The intro modal trigger logic should be in the `Home.tsx` component, not in the tabs `_layout.tsx`. Triggering modals from tab layout can interfere with native bottom tabs navigation.

View File

@@ -1,9 +0,0 @@
# IntroSheet Rendering Location
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `providers/IntroSheetProvider`, `components/IntroSheet`
## Detail
The `IntroSheet` component is rendered inside `IntroSheetProvider` which wraps the entire navigation stack. Any hooks in IntroSheet that interact with navigation state can affect the native bottom tabs.

View File

@@ -1,9 +0,0 @@
# macOS Header Buttons Fix
**Date**: 2026-01-10
**Category**: ui
**Key files**: `components/common/HeaderBackButton.tsx`, `app/(auth)/(tabs)/(home)/_layout.tsx`
## Detail
Header buttons (`headerRight`/`headerLeft`) don't respond to touches on macOS Catalyst builds when using standard React Native `TouchableOpacity`. Fix by using `Pressable` from `react-native-gesture-handler` instead. The library is already installed and `GestureHandlerRootView` wraps the app.

View File

@@ -1,9 +0,0 @@
# Mark as Played Flow
**Date**: 2026-01-10
**Category**: state-management
**Key files**: `components/PlayedStatus.tsx`, `hooks/useMarkAsPlayed.ts`
## Detail
The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`.

View File

@@ -1,9 +0,0 @@
# MPV avfoundation-composite-osd Ordering
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support).

View File

@@ -1,9 +0,0 @@
# MPV tvOS Player Exit Freeze
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first.

View File

@@ -1,9 +0,0 @@
# Native Bottom Tabs + useRouter Conflict
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `providers/`, `app/_layout.tsx`
## Detail
When using `@bottom-tabs/react-navigation` with Expo Router, avoid using the `useRouter()` hook in components rendered at the provider level (outside the tab navigator). The hook subscribes to navigation state changes and can cause unexpected tab switches. Use the static `router` import from `expo-router` instead.

View File

@@ -1,9 +0,0 @@
# Native SwiftUI View Sizing
**Date**: 2026-01-25
**Category**: native-modules
**Key files**: `modules/`
## Detail
When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing.

View File

@@ -1,9 +0,0 @@
# Platform-Specific File Suffix (.tv.tsx) Does NOT Work
**Date**: 2026-01-26
**Category**: tv
**Key files**: `app/`, `components/`
## Detail
The `.tv.tsx` file suffix does NOT work for either pages or components in this project. Metro bundler doesn't resolve platform-specific suffixes. Instead, use `Platform.isTV` conditional rendering within a single file. For pages: check `Platform.isTV` at the top and return the TV component early. For components: create separate `MyComponent.tsx` and `TVMyComponent.tsx` files and use `Platform.isTV` to choose which to render.

View File

@@ -1,9 +0,0 @@
# Stack Screen Header Configuration
**Date**: 2026-01-10
**Category**: ui
**Key files**: `app/(auth)/(tabs)/(home)/_layout.tsx`
## Detail
Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling.

View File

@@ -1,9 +0,0 @@
# Streamystats Components Location
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/home/StreamystatsRecommendations.tv.tsx`, `components/home/StreamystatsPromotedWatchlists.tv.tsx`, `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`
## Detail
Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`.

View File

@@ -1,9 +0,0 @@
# Tab Folder Naming
**Date**: 2025-01-09
**Category**: navigation
**Key files**: `app/(auth)/(tabs)/`
## Detail
The tab folders use underscore prefix naming like `(_home)` instead of just `(home)` based on the project's file structure conventions.

View File

@@ -1,9 +0,0 @@
# Thread-Safe State for Stop Flags
**Date**: 2026-01-22
**Category**: native-modules
**Key files**: `modules/mpv-player/ios/MPVLayerRenderer.swift`
## Detail
When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time.

View File

@@ -1,9 +0,0 @@
# TV Grid Layout Pattern
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/tv/`
## Detail
For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing.

View File

@@ -1,9 +0,0 @@
# TV Horizontal Padding Standard
**Date**: 2026-01-25
**Category**: tv
**Key files**: `components/tv/`, `app/(auth)/(tabs)/`
## Detail
TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small.

View File

@@ -1,9 +0,0 @@
# TV Modals Must Use Navigation Pattern
**Date**: 2026-01-24
**Category**: tv
**Key files**: `hooks/useTVOptionModal.ts`, `app/(auth)/tv-option-modal.tsx`
## Detail
On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page.

View File

@@ -1,9 +0,0 @@
# useNetworkAwareQueryClient Limitations
**Date**: 2026-01-10
**Category**: state-management
**Key files**: `hooks/useNetworkAwareQueryClient.ts`
## Detail
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.

View File

@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: 🔍 Get PR and Artifacts - name: 🔍 Get PR and Artifacts
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with: with:
script: | script: |
// Check if we're running from a fork (more precise detection) // Check if we're running from a fork (more precise detection)
@@ -188,17 +188,6 @@ jobs:
if (latestAppsRun) { if (latestAppsRun) {
console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`); console.log(`Getting individual job statuses for run ${latestAppsRun.id} (status: ${latestAppsRun.status}, conclusion: ${latestAppsRun.conclusion || 'none'})`);
// Map job names to our build targets. Declared outside the try so
// the catch fallback can reuse the same keys.
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone'],
'iOS Unsigned': ['🍎 Build iOS IPA (Phone - Unsigned)', 'build-ios-phone-unsigned'],
'tvOS': ['🍎 Build tvOS IPA', 'build-ios-tv'],
'tvOS Unsigned': ['🍎 Build tvOS IPA (Unsigned)', 'build-ios-tv-unsigned']
};
try { try {
// Get all jobs for this workflow run // Get all jobs for this workflow run
const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({
@@ -227,6 +216,13 @@ jobs:
return; // Exit early return; // Exit early
} }
// Map job names to our build targets
const jobMappings = {
'Android Phone': ['🤖 Build Android APK (Phone)', 'build-android-phone'],
'Android TV': ['🤖 Build Android APK (TV)', 'build-android-tv'],
'iOS Phone': ['🍎 Build iOS IPA (Phone)', 'build-ios-phone']
};
// Create individual status for each job // Create individual status for each job
for (const [platform, jobNames] of Object.entries(jobMappings)) { for (const [platform, jobNames] of Object.entries(jobMappings)) {
const job = jobs.jobs.find(j => const job = jobs.jobs.find(j =>
@@ -240,9 +236,7 @@ jobs:
conclusion: job.conclusion, conclusion: job.conclusion,
url: job.html_url, url: job.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: job.started_at || latestAppsRun.created_at, created_at: job.started_at || latestAppsRun.created_at
started_at: job.started_at,
completed_at: job.completed_at
}; };
console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`); console.log(`Mapped ${platform} to job: ${job.name} (${job.status}/${job.conclusion || 'none'})`);
} else { } else {
@@ -253,30 +247,22 @@ jobs:
conclusion: latestAppsRun.conclusion, conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url, url: latestAppsRun.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: latestAppsRun.created_at, created_at: latestAppsRun.created_at
started_at: latestAppsRun.run_started_at,
completed_at: latestAppsRun.updated_at
}; };
} }
} }
} catch (error) { } catch (error) {
console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message); console.log(`Failed to get jobs for run ${latestAppsRun.id}:`, error.message);
// Fallback to workflow-level status for every build target. // Fallback to workflow-level status
// Keys must match jobMappings / buildTargets statusKey values. buildStatuses['Android Phone'] = buildStatuses['Android TV'] = buildStatuses['iOS Phone'] = {
const fallbackStatus = {
name: latestAppsRun.name, name: latestAppsRun.name,
status: latestAppsRun.status, status: latestAppsRun.status,
conclusion: latestAppsRun.conclusion, conclusion: latestAppsRun.conclusion,
url: latestAppsRun.html_url, url: latestAppsRun.html_url,
runId: latestAppsRun.id, runId: latestAppsRun.id,
created_at: latestAppsRun.created_at, created_at: latestAppsRun.created_at
started_at: latestAppsRun.run_started_at,
completed_at: latestAppsRun.updated_at
}; };
for (const platform of Object.keys(jobMappings)) {
buildStatuses[platform] = fallbackStatus;
}
} }
// Collect artifacts if any job has completed successfully // Collect artifacts if any job has completed successfully
@@ -367,12 +353,10 @@ jobs:
// Process each expected build target individually // Process each expected build target individually
const buildTargets = [ const buildTargets = [
{ name: 'Android Phone', platform: '🤖', device: '📱 Phone', statusKey: 'Android Phone', artifactPattern: /android.*phone/i }, { name: 'Android Phone', platform: '🤖', device: '📱', statusKey: 'Android Phone', artifactPattern: /android.*phone/i },
{ name: 'Android TV', platform: '🤖', device: '📺 TV', statusKey: 'Android TV', artifactPattern: /android.*tv/i }, { name: 'Android TV', platform: '🤖', device: '📺', statusKey: 'Android TV', artifactPattern: /android.*tv/i },
{ name: 'iOS', platform: '🍎', device: '📱 Phone', statusKey: 'iOS', artifactPattern: /ios.*phone.*ipa(?!.*unsigned)/i }, { name: 'iOS Phone', platform: '🍎', device: '📱', statusKey: 'iOS Phone', artifactPattern: /ios.*phone/i },
{ name: 'iOS Unsigned', platform: '🍎', device: '📱 Phone Unsigned', statusKey: 'iOS Unsigned', artifactPattern: /ios.*phone.*unsigned/i }, { name: 'iOS TV', platform: '🍎', device: '📺', statusKey: 'iOS TV', artifactPattern: /ios.*tv/i }
{ name: 'tvOS', platform: '🍎', device: '📺 TV', statusKey: 'tvOS', artifactPattern: /ios.*tv.*ipa(?!.*unsigned)/i },
{ name: 'tvOS Unsigned', platform: '🍎', device: '📺 TV Unsigned', statusKey: 'tvOS Unsigned', artifactPattern: /ios.*tv.*unsigned/i }
]; ];
for (const target of buildTargets) { for (const target of buildTargets) {
@@ -387,31 +371,16 @@ jobs:
let status = '⏳ Pending'; let status = '⏳ Pending';
let downloadLink = '*Waiting for build...*'; let downloadLink = '*Waiting for build...*';
// tvOS builds are temporarily disabled until feat/tv-interface // Special case for iOS TV - show as disabled
// is merged - show them as disabled instead of stuck pending. if (target.name === 'iOS TV') {
if (target.name === 'tvOS' || target.name === 'tvOS Unsigned') {
status = '💤 Disabled'; status = '💤 Disabled';
downloadLink = '*Disabled until feat/tv-interface is merged*'; downloadLink = '*Disabled for now*';
} else if (matchingStatus) { } else if (matchingStatus) {
if (matchingStatus.conclusion === 'success' && matchingArtifact) { if (matchingStatus.conclusion === 'success' && matchingArtifact) {
status = '✅ Complete'; status = '✅ Complete';
const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`; const directLink = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${matchingArtifact.workflow_run.id}/artifacts/${matchingArtifact.id}`;
const fileType = target.name.includes('Android') ? 'APK' : 'IPA'; const fileType = target.name.includes('Android') ? 'APK' : 'IPA';
downloadLink = `[📥 Download ${fileType}](${directLink})`;
// Format file size
const sizeInMB = (matchingArtifact.size_in_bytes / (1024 * 1024)).toFixed(1);
const sizeInfo = `(${sizeInMB} MB)`;
// Calculate build duration
let durationInfo = '';
if (matchingStatus.started_at && matchingStatus.completed_at) {
const durationMs = new Date(matchingStatus.completed_at) - new Date(matchingStatus.started_at);
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
durationInfo = ` - ${durationMin}m ${durationSec}s`;
}
downloadLink = `[📥 Download ${fileType}](${directLink}) ${sizeInfo}${durationInfo}`;
} else if (matchingStatus.conclusion === 'failure') { } else if (matchingStatus.conclusion === 'failure') {
status = `❌ [Failed](${matchingStatus.url})`; status = `❌ [Failed](${matchingStatus.url})`;
downloadLink = '*Build failed*'; downloadLink = '*Build failed*';
@@ -439,7 +408,7 @@ jobs:
} }
} }
commentBody += `| ${target.platform} ${target.name} | ${target.device} | ${status} | ${downloadLink} |\n`; commentBody += `| ${target.platform} ${target.name.split(' ')[0]} | ${target.device} ${target.name.split(' ')[1]} | ${status} | ${downloadLink} |\n`;
} }
commentBody += `\n`; commentBody += `\n`;

View File

@@ -41,12 +41,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -60,7 +60,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -73,7 +73,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -88,7 +88,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-android-phone-apk-${{ env.DATE_TAG }} name: streamyfin-android-phone-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -124,12 +124,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
@@ -143,7 +143,7 @@ jobs:
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Gradle global
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -156,7 +156,7 @@ jobs:
run: bun run prebuild:tv run: bun run prebuild:tv
- name: 💾 Cache project Gradle (.gradle) - name: 💾 Cache project Gradle (.gradle)
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: android/.gradle path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
@@ -171,7 +171,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-android-tv-apk-${{ env.DATE_TAG }} name: streamyfin-android-tv-apk-${{ env.DATE_TAG }}
path: | path: |
@@ -195,12 +195,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -216,12 +216,12 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with: with:
xcode-version: "26.2" xcode-version: "26.2"
- name: 🏗️ Setup EAS - name: 🏗️ Setup EAS
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main uses: expo/expo-github-action@main
with: with:
eas-version: latest eas-version: latest
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: build-*.ipa
@@ -259,12 +259,12 @@ jobs:
show-progress: false show-progress: false
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
@@ -280,7 +280,7 @@ jobs:
run: bun run prebuild run: bun run prebuild
- name: 🔧 Setup Xcode - name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with: with:
xcode-version: "26.2" xcode-version: "26.2"
@@ -293,133 +293,73 @@ jobs:
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }} name: streamyfin-ios-phone-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa path: build/*.ipa
retention-days: 7 retention-days: 7
build-ios-tv: # Disabled for now - uncomment when ready to build iOS TV
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready). # build-ios-tv:
# Re-enable by removing the `false &&` prefix below. # 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 && (!contains(github.event.head_commit.message, '[skip ci]') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin')) # runs-on: macos-26
runs-on: macos-26 # name: 🍎 Build iOS IPA (TV)
name: 🍎 Build tvOS IPA # permissions:
permissions: # contents: read
contents: read #
# steps:
steps: # - name: 📥 Checkout code
- name: 📥 Checkout code # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # with:
with: # ref: ${{ github.event.pull_request.head.sha || github.sha }}
ref: ${{ github.event.pull_request.head.sha || github.sha }} # fetch-depth: 0
fetch-depth: 0 # submodules: recursive
submodules: recursive # show-progress: false
show-progress: false #
# - name: 🍞 Setup Bun
- name: 🍞 Setup Bun # uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 # with:
with: # bun-version: latest
bun-version: latest #
# - name: 💾 Cache Bun dependencies
- name: 💾 Cache Bun dependencies # uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 # with:
with: # path: ~/.bun/install/cache
path: ~/.bun/install/cache # key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} # restore-keys: |
restore-keys: | # ${{ runner.os }}-bun-cache
${{ runner.os }}-bun-cache #
# - name: 📦 Install dependencies and reload submodules
- name: 📦 Install dependencies and reload submodules # run: |
run: | # bun install --frozen-lockfile
bun install --frozen-lockfile # bun run submodule-reload
bun run submodule-reload #
# - name: 🛠️ Generate project files
- name: 🛠️ Generate project files # run: bun run prebuild:tv
run: bun run prebuild:tv #
# - name: 🔧 Setup Xcode
- name: 🔧 Setup Xcode # uses: maxim-lobanov/setup-xcode@v1
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 # with:
with: # xcode-version: '26.0.1'
xcode-version: "26.2" #
# - name: 🏗️ Setup EAS
- name: 🏗️ Setup EAS # uses: expo/expo-github-action@main
uses: expo/expo-github-action@b184ff86a3c926240f1b6db41764c83a01c02eef # main # with:
with: # eas-version: latest
eas-version: latest # token: ${{ secrets.EXPO_TOKEN }}
token: ${{ secrets.EXPO_TOKEN }} # eas-cache: true
eas-cache: true #
# - name: 🚀 Build iOS app
- name: 🚀 Build iOS app # env:
env: # EXPO_TV: 1
EXPO_TV: 1 # run: eas build -p ios --local --non-interactive
run: eas build -p ios --local --non-interactive #
# - name: 📅 Set date tag
- name: 📅 Set date tag # run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV #
# - name: 📤 Upload IPA artifact
- name: 📤 Upload IPA artifact # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # with:
with: # name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }}
name: streamyfin-ios-tv-ipa-${{ env.DATE_TAG }} # path: build-*.ipa
path: build-*.ipa # retention-days: 7
retention-days: 7
build-ios-tv-unsigned:
# Temporarily disabled until feat/tv-interface is merged (TV UI not ready).
# Re-enable by removing the `false &&` prefix below.
if: false && (!contains(github.event.head_commit.message, '[skip ci]'))
runs-on: macos-26
name: 🍎 Build tvOS IPA (Unsigned)
permissions:
contents: read
steps:
- name: 📥 Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
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: 🛠️ Generate project files
run: bun run prebuild:tv
- name: 🔧 Setup Xcode
uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1
with:
xcode-version: "26.2"
- name: 🚀 Build iOS app
env:
EXPO_TV: 1
run: bun run ios:unsigned-build:tv ${{ github.event_name == 'pull_request' && '-- --verbose' || '' }}
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: streamyfin-ios-tv-unsigned-ipa-${{ env.DATE_TAG }}
path: build/*.ipa
retention-days: 7

View File

@@ -27,12 +27,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -27,13 +27,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/autobuild@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2 uses: crowdin/github-action@5587c43063e52090026857d386174d2599ad323b # v2.14.1
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -25,7 +25,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
with: with:
header: pr-title-lint-error header: pr-title-lint-error
@@ -39,7 +39,7 @@ jobs:
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true
@@ -57,7 +57,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with: with:
fail-on-severity: high fail-on-severity: high
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
@@ -76,7 +76,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest
@@ -107,12 +107,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
with: with:
bun-version: latest bun-version: latest

View File

@@ -21,14 +21,14 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'
- name: 🔍 Extract minor version from app.json - name: 🔍 Extract minor version from app.json
id: minor id: minor
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # main uses: actions/github-script@main
with: with:
result-encoding: string result-encoding: string
script: | script: |
@@ -54,7 +54,7 @@ jobs:
dry_run: no-push dry_run: no-push
- name: 📬 Commit and create pull request - name: 📬 Commit and create pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with: with:
add-paths: .github/ISSUE_TEMPLATE/bug_report.yml add-paths: .github/ISSUE_TEMPLATE/bug_report.yml
branch: ci-update-bug-report branch: ci-update-bug-report

7
.gitignore vendored
View File

@@ -50,6 +50,8 @@ npm-debug.*
.idea/ .idea/
.ruby-lsp .ruby-lsp
.cursor/ .cursor/
.claude/
CLAUDE.md
# Environment and Configuration # Environment and Configuration
expo-env.d.ts expo-env.d.ts
@@ -61,8 +63,6 @@ expo-env.d.ts
pc-api-7079014811501811218-719-3b9f15aeccf8.json pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json credentials.json
streamyfin-4fec1-firebase-adminsdk.json streamyfin-4fec1-firebase-adminsdk.json
/profiles/
certs/
# Version and Backup Files # Version and Backup Files
/version-backup-* /version-backup-*
@@ -72,5 +72,4 @@ modules/background-downloader/android/build/*
/modules/mpv-player/android/build /modules/mpv-player/android/build
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/
.claude/

158
CLAUDE.md
View File

@@ -1,39 +1,9 @@
# CLAUDE.md # CLAUDE.md
@.claude/learned-facts.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Learned Facts Index
IMPORTANT: When encountering issues related to these topics, or when implementing new features that touch these areas, prefer retrieval-led reasoning -- read the relevant fact file in `.claude/learned-facts/` before relying on assumptions.
Navigation:
- `native-bottom-tabs-userouter-conflict` | useRouter() at provider level causes tab switches; use static router import
- `introsheet-rendering-location` | IntroSheet in IntroSheetProvider affects native bottom tabs via nav state hooks
- `intro-modal-trigger-location` | Trigger in Home.tsx, not tabs _layout.tsx
- `tab-folder-naming` | Use underscore prefix: (_home) not (home)
UI/Headers:
- `macos-header-buttons-fix` | macOS Catalyst: use RNGH Pressable, not RN TouchableOpacity
- `header-button-locations` | Defined in _layout.tsx, HeaderBackButton, Chromecast, RoundButton, etc.
- `stack-screen-header-configuration` | Sub-pages need explicit Stack.Screen with headerTransparent + back button
State/Data:
- `use-network-aware-query-client-limitations` | Object.create breaks private fields; only for invalidateQueries
- `mark-as-played-flow` | PlayedStatus→useMarkAsPlayed→playbackManager with optimistic updates
Native Modules:
- `mpv-tvos-player-exit-freeze` | mpv_terminate_destroy deadlocks main thread; use DispatchQueue.global()
- `mpv-avfoundation-composite-osd-ordering` | MUST follow vo=avfoundation, before hwdec options
- `thread-safe-state-for-stop-flags` | Stop flags need synchronous setter (stateQueue.sync not async)
- `native-swiftui-view-sizing` | Need explicit frame + intrinsicContentSize override in ExpoView
TV Platform:
- `tv-modals-must-use-navigation-pattern` | Use atom+router.push(), never overlay/absolute modals
- `tv-grid-layout-pattern` | ScrollView+flexWrap, not FlatList numColumns
- `tv-horizontal-padding-standard` | TV_HORIZONTAL_PADDING=60, not old TV_SCALE_PADDING=20
- `streamystats-components-location` | components/home/Streamystats*.tv.tsx, watchlists/[watchlistId].tsx
- `platform-specific-file-suffix-does-not-work` | .tv.tsx doesn't work; use Platform.isTV conditional rendering
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration. Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
@@ -95,7 +65,6 @@ bun run ios:install-metal-toolchain # Fix "missing Metal Toolchain" build error
**State Management**: **State Management**:
- Global state uses Jotai atoms in `utils/atoms/` - Global state uses Jotai atoms in `utils/atoms/`
- `settingsAtom` in `utils/atoms/settings.ts` for app settings - `settingsAtom` in `utils/atoms/settings.ts` for app settings
- **IMPORTANT**: When adding a setting to the settings atom, ensure it's toggleable in the settings view (either TV or mobile, depending on the feature scope)
- `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state - `apiAtom` and `userAtom` in `providers/JellyfinProvider.tsx` for auth state
- Server state uses React Query with `@tanstack/react-query` - Server state uses React Query with `@tanstack/react-query`
@@ -159,132 +128,9 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Use existing atoms, hooks, and utilities before creating new ones - Use existing atoms, hooks, and utilities before creating new ones
- Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):` - Use Conventional Commits: `feat(scope):`, `fix(scope):`, `chore(scope):`
- **Translations**: When adding a translation key to a Text component, ensure the key exists in both `translations/en.json` and `translations/sv.json`. Before adding new keys, check if an existing key already covers the use case.
## Platform Considerations ## Platform Considerations
- TV version uses `:tv` suffix for scripts - TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"` - Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- Some features disabled on TV (e.g., notifications, Chromecast) - Some features disabled on TV (e.g., notifications, Chromecast)
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
- **TV Modals**: Never use React Native's `Modal` component or overlay/absolute-positioned modals for full-screen modals on TV. Use the navigation-based modal pattern instead. **See [docs/tv-modal-guide.md](docs/tv-modal-guide.md) for detailed documentation.**
### TV Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix does NOT work in this project - neither for pages nor components. Metro bundler doesn't resolve platform-specific suffixes. Always use `Platform.isTV` conditional rendering instead.
**Pattern for TV-specific pages and components**:
```typescript
// In page file (e.g., app/login.tsx)
import { Platform } from "react-native";
import { Login } from "@/components/login/Login";
import { TVLogin } from "@/components/login/TVLogin";
const LoginPage: React.FC = () => {
if (Platform.isTV) {
return <TVLogin />;
}
return <Login />;
};
export default LoginPage;
```
- Create separate component files for mobile and TV (e.g., `MyComponent.tsx` and `TVMyComponent.tsx`)
- Use `Platform.isTV` to conditionally render the appropriate component
- TV components typically use `TVInput`, `TVServerCard`, and other TV-prefixed components with focus handling
- **Never use `.tv.tsx` file suffix** - it will not be resolved correctly
### TV Option Selectors and Focus Management
For dropdown/select components, bottom sheets, and overlay focus management on TV, see [docs/tv-modal-guide.md](docs/tv-modal-guide.md).
### TV Focus Flickering Between Zones (Lists with Headers)
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
**Solutions:**
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
```typescript
{Platform.isTV ? (
<FlatList
data={items}
renderItem={renderTVItem}
removeClippedSubviews={false}
// ...
/>
) : (
<FlashList data={items} renderItem={renderItem} />
)}
```
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
```typescript
// ✅ Good - only first filter button has preferred focus
<TVFilterButton hasTVPreferredFocus={index === 0} />
<TVFocusablePoster /> // No hasTVPreferredFocus
// ❌ Bad - both compete for focus
<TVFilterButton hasTVPreferredFocus />
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
```
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
```typescript
<View style={{ flex: 1 }}>
{/* Filter bar - separate from list */}
<View style={{ flexDirection: "row", gap: 12 }}>
<TVFilterButton />
<TVFilterButton />
</View>
{/* Grid */}
<FlatList data={items} renderItem={renderTVItem} />
</View>
```
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
### TV Focus Guide Navigation (Non-Adjacent Sections)
When you need focus to navigate between sections that aren't geometrically aligned (e.g., left-aligned buttons to a horizontal ScrollView), use `TVFocusGuideView` with the `destinations` prop:
```typescript
// 1. Track destination with useState (NOT useRef - won't trigger re-renders)
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
// 2. Place invisible focus guide between sections
{firstCardRef && (
<TVFocusGuideView
destinations={[firstCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
// 3. Target component must use forwardRef
const MyCard = React.forwardRef<View, Props>(({ ... }, ref) => (
<Pressable ref={ref} ...>
...
</Pressable>
));
// 4. Pass state setter as callback ref to first item
{items.map((item, index) => (
<MyCard
ref={index === 0 ? setFirstCardRef : undefined}
...
/>
))}
```
**For detailed documentation and bidirectional navigation patterns, see [docs/tv-focus-guide.md](docs/tv-focus-guide.md)**
**Reference implementation**: See `components/ItemContent.tv.tsx` for bidirectional focus navigation between playback options and cast list.

View File

@@ -126,10 +126,6 @@ For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv` `npm run prebuild:tv`
`npm run ios:tv or npm run android:tv` `npm run ios:tv or npm run android:tv`
TV platform integration notes:
- [TV Discovery](./docs/tv-discovery.md)
## 👋 Get in Touch with Us ## 👋 Get in Touch with Us
Need assistance or have any questions? Need assistance or have any questions?

View File

@@ -6,14 +6,6 @@ module.exports = ({ config }) => {
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
config.plugins.push([
"expo-camera",
{
cameraPermission:
"Allow Streamyfin to access the camera to scan QR codes for TV login.",
},
]);
} }
// Only override googleServicesFile if env var is set // Only override googleServicesFile if env var is set

View File

@@ -7,6 +7,8 @@
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"jsEngine": "hermes",
"newArchEnabled": true,
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "requireFullScreen": true,
@@ -21,8 +23,7 @@
}, },
"UISupportsTrueScreenSizeOnMac": true, "UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true, "UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true, "LSSupportsOpeningDocumentsInPlace": true
"AVInitialRouteSharingPolicy": "LongFormAudio"
}, },
"config": { "config": {
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
@@ -36,6 +37,7 @@
"appleTeamId": "MWD5K362T8" "appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes",
"versionCode": 92, "versionCode": 92,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
@@ -53,27 +55,10 @@
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
[ "@react-native-tvos/config-tv",
"@react-native-tvos/config-tv",
{
"appleTVImages": {
"icon": "./assets/images/icon-tvos.png",
"iconSmall": "./assets/images/icon-tvos-small.png",
"iconSmall2x": "./assets/images/icon-tvos-small-2x.png",
"topShelf": "./assets/images/icon-tvos-topshelf.png",
"topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png",
"topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png",
"topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png"
},
"infoPlist": {
"UIAppSupportsHDR": true
}
}
],
"expo-router", "expo-router",
"expo-font", "expo-font",
"./plugins/withExcludeMedia3Dash.js", "./plugins/withExcludeMedia3Dash.js",
"./plugins/withTVUserManagement.js",
[ [
"expo-build-properties", "expo-build-properties",
{ {
@@ -82,7 +67,7 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"buildArchs": ["arm64-v8a", "x86_64", "armeabi-v7a"], "buildArchs": ["arm64-v8a", "x86_64"],
"compileSdkVersion": 36, "compileSdkVersion": 36,
"targetSdkVersion": 35, "targetSdkVersion": 35,
"buildToolsVersion": "35.0.0", "buildToolsVersion": "35.0.0",
@@ -133,13 +118,9 @@
"expo-web-browser", "expo-web-browser",
["./plugins/with-runtime-framework-headers.js"], ["./plugins/with-runtime-framework-headers.js"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidAlertColors.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.js"],
["./plugins/withTVOSTopShelf.js"],
["./plugins/withTVXcodeEnv.js"],
[ [
"./plugins/withGitPod.js", "./plugins/withGitPod.js",
{ {

View File

@@ -9,7 +9,7 @@ export default function CustomMenuLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: Platform.OS !== "ios",
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.custom_links"), headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none", headerBlurEffect: "none",

View File

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

View File

@@ -1,11 +1,9 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Platform, RefreshControl, ScrollView, View } from "react-native"; import { Platform, RefreshControl, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Favorites } from "@/components/home/Favorites";
import { Favorites as TVFavorites } from "@/components/home/Favorites.tv";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export default function favorites() { export default function Favorites() {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -16,10 +14,6 @@ export default function favorites() {
}, []); }, []);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (Platform.isTV) {
return <TVFavorites />;
}
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled

View File

@@ -43,7 +43,7 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='downloads/index' name='downloads/index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"), title: t("home.downloads.downloads_title"),
@@ -62,7 +62,7 @@ export default function IndexLayout() {
name='sessions/index' name='sessions/index'
options={{ options={{
title: t("home.sessions.title"), title: t("home.sessions.title"),
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -81,26 +81,6 @@ export default function IndexLayout() {
name='settings' name='settings'
options={{ options={{
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<Pressable
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</Pressable>
),
}}
/>
<Stack.Screen
name='companion-login'
options={{
title: t("companion_login.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -119,7 +99,6 @@ export default function IndexLayout() {
name='settings/playback-controls/page' name='settings/playback-controls/page'
options={{ options={{
title: t("home.settings.playback_controls.title"), title: t("home.settings.playback_controls.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -138,7 +117,6 @@ export default function IndexLayout() {
name='settings/audio-subtitles/page' name='settings/audio-subtitles/page'
options={{ options={{
title: t("home.settings.audio_subtitles.title"), title: t("home.settings.audio_subtitles.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -157,7 +135,6 @@ export default function IndexLayout() {
name='settings/appearance/page' name='settings/appearance/page'
options={{ options={{
title: t("home.settings.appearance.title"), title: t("home.settings.appearance.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -176,7 +153,6 @@ export default function IndexLayout() {
name='settings/music/page' name='settings/music/page'
options={{ options={{
title: t("home.settings.music.title"), title: t("home.settings.music.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -195,7 +171,6 @@ export default function IndexLayout() {
name='settings/appearance/hide-libraries/page' name='settings/appearance/hide-libraries/page'
options={{ options={{
title: t("home.settings.other.hide_libraries"), title: t("home.settings.other.hide_libraries"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -214,7 +189,6 @@ export default function IndexLayout() {
name='settings/plugins/page' name='settings/plugins/page'
options={{ options={{
title: t("home.settings.plugins.plugins_title"), title: t("home.settings.plugins.plugins_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -233,7 +207,6 @@ export default function IndexLayout() {
name='settings/plugins/marlin-search/page' name='settings/plugins/marlin-search/page'
options={{ options={{
title: "Marlin Search", title: "Marlin Search",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -252,7 +225,6 @@ export default function IndexLayout() {
name='settings/plugins/jellyseerr/page' name='settings/plugins/jellyseerr/page'
options={{ options={{
title: "Jellyseerr", title: "Jellyseerr",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -271,7 +243,6 @@ export default function IndexLayout() {
name='settings/plugins/streamystats/page' name='settings/plugins/streamystats/page'
options={{ options={{
title: "Streamystats", title: "Streamystats",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -290,7 +261,6 @@ export default function IndexLayout() {
name='settings/plugins/kefinTweaks/page' name='settings/plugins/kefinTweaks/page'
options={{ options={{
title: "KefinTweaks", title: "KefinTweaks",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -309,7 +279,6 @@ export default function IndexLayout() {
name='settings/intro/page' name='settings/intro/page'
options={{ options={{
title: t("home.settings.intro.title"), title: t("home.settings.intro.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -328,7 +297,6 @@ export default function IndexLayout() {
name='settings/logs/page' name='settings/logs/page'
options={{ options={{
title: t("home.settings.logs.logs_title"), title: t("home.settings.logs.logs_title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -347,7 +315,6 @@ export default function IndexLayout() {
name='settings/network/page' name='settings/network/page'
options={{ options={{
title: t("home.settings.network.title"), title: t("home.settings.network.title"),
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -374,7 +341,7 @@ export default function IndexLayout() {
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</Pressable> </Pressable>
), ),
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -1,7 +0,0 @@
import { Platform } from "react-native";
import { CompanionLoginScreen } from "@/components/companion/CompanionLoginScreen";
export default function CompanionLoginPage() {
if (Platform.isTV) return null;
return <CompanionLoginScreen />;
}

View File

@@ -20,7 +20,7 @@ import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
export default function page() { export default function Page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [_queue, _setQueue] = useAtom(queueAtom); const [_queue, _setQueue] = useAtom(queueAtom);

View File

@@ -1,6 +1,15 @@
import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home"; import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
const Index = () => { const Index = () => {
const { settings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />; return <Home />;
}; };

View File

@@ -23,7 +23,7 @@ import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
export default function page() { export default function Page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps); const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -14,11 +14,7 @@ import { UserInfo } from "@/components/settings/UserInfo";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
// TV-specific settings component export default function Settings() {
const SettingsTV = Platform.isTV ? require("./settings.tv").default : null;
// Mobile settings component
function SettingsMobile() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom); const [_user] = useAtom(userAtom);
@@ -59,18 +55,6 @@ function SettingsMobile() {
<QuickConnect className='mb-4' /> <QuickConnect className='mb-4' />
<TouchableOpacity
className='mb-4 p-4 rounded-xl bg-neutral-900 border border-neutral-800'
onPress={() => router.push("/(auth)/(tabs)/(home)/companion-login")}
>
<Text className='text-white font-bold text-base mb-1'>
{t("pairing.pair_with_phone_title")}
</Text>
<Text className='text-neutral-400 text-sm'>
{t("pairing.pair_with_phone_description")}
</Text>
</TouchableOpacity>
<View className='mb-4'> <View className='mb-4'>
<AppLanguageSelector /> <AppLanguageSelector />
</View> </View>
@@ -120,17 +104,8 @@ function SettingsMobile() {
</ListGroup> </ListGroup>
</View> </View>
<StorageSettings /> {!Platform.isTV && <StorageSettings />}
</View> </View>
</ScrollView> </ScrollView>
); );
} }
export default function settings() {
// Use TV settings component on TV platforms
if (Platform.isTV && SettingsTV) {
return <SettingsTV />;
}
return <SettingsMobile />;
}

View File

@@ -1,949 +0,0 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
import type { TVOptionItem } from "@/components/tv";
import {
TVLogoutButton,
TVSectionHeader,
TVSettingsOptionButton,
TVSettingsRow,
TVSettingsStepper,
TVSettingsTextInput,
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n";
import { clearCache as clearAudioCache } from "@/providers/AudioStorage";
import {
apiAtom,
cacheVersionAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
import {
AudioTranscodeMode,
InactivityTimeout,
type MpvCacheMode,
type MpvVoDriver,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv";
import {
getPreviousServers,
type SavedServer,
type SavedServerAccount,
} from "@/utils/secureCredentials";
import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const [, setCacheVersion] = useAtom(cacheVersionAtom);
const { showOptions } = useTVOptionModal();
const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography();
const queryClient = useQueryClient();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings.openSubtitlesApiKey || "",
);
// PIN/Password modal state for user switching
const [pinModalVisible, setPinModalVisible] = useState(false);
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
null,
);
const [selectedAccount, setSelectedAccount] =
useState<SavedServerAccount | null>(null);
// Track if any modal is open to disable background focus
const isAnyModalOpen = pinModalVisible || passwordModalVisible;
// Get current server and other accounts
const currentServer = useMemo(() => {
if (!api?.basePath) return null;
const servers = getPreviousServers();
return servers.find((s) => s.address === api.basePath) || null;
}, [api?.basePath]);
const otherAccounts = useMemo(() => {
if (!currentServer || !user?.Id) return [];
return currentServer.accounts.filter(
(account) => account.userId !== user.Id,
);
}, [currentServer, user?.Id]);
const hasOtherAccounts = otherAccounts.length > 0;
// Handle account selection from modal
const handleAccountSelect = async (account: SavedServerAccount) => {
if (!currentServer) return;
if (account.securityType === "none") {
// Direct login with saved credential
try {
await loginWithSavedCredential(currentServer.address, account.userId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
} else if (account.securityType === "pin") {
// Show PIN modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPinModalVisible(true);
} else if (account.securityType === "password") {
// Show password modal
setSelectedServer(currentServer);
setSelectedAccount(account);
setPasswordModalVisible(true);
}
};
// Handle successful PIN entry
const handlePinSuccess = async () => {
setPinModalVisible(false);
if (selectedServer && selectedAccount) {
try {
await loginWithSavedCredential(
selectedServer.address,
selectedAccount.userId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("server.session_expired");
const isSessionExpired = errorMessage.includes(
t("server.session_expired"),
);
Alert.alert(
isSessionExpired
? t("server.session_expired")
: t("login.connection_failed"),
isSessionExpired ? t("server.please_login_again") : errorMessage,
);
}
}
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle password submission
const handlePasswordSubmit = async (password: string) => {
if (selectedServer && selectedAccount) {
await loginWithPassword(
selectedServer.address,
selectedAccount.username,
password,
);
}
setPasswordModalVisible(false);
setSelectedServer(null);
setSelectedAccount(null);
};
// Handle switch user button press
const handleSwitchUser = () => {
if (!currentServer || !user?.Id) return;
showUserSwitchModal(currentServer, user.Id, {
onAccountSelect: handleAccountSelect,
});
};
// Handle clearing all cache in the entire app
const handleClearCache = async () => {
Alert.alert(
t("home.settings.storage.clear_all_cache_confirm", "Clear All Cache?"),
t(
"home.settings.storage.clear_all_cache_confirm_desc",
"Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
),
[
{
text: t("common.cancel", "Cancel"),
style: "cancel",
},
{
text: t("common.ok", "OK"),
onPress: async () => {
try {
// 1. Clear React Query Cache (memory & MMKV)
storage.remove("REACT_QUERY_OFFLINE_CACHE");
await queryClient.resetQueries();
// 2. Clear expo-image cache (memory & disk)
await Image.clearDiskCache();
Image.clearMemoryCache();
// 3. Clear AudioStorage (music) cache
await clearAudioCache();
// 4. Clear TopShelf cache
clearTopShelfCacheSafely();
// 5. Clear Subtitle Cache
storage.remove("downloadedSubtitles.json");
const subtitlesDir = new Directory(
Paths.cache,
"streamyfin-subtitles",
);
if (subtitlesDir.exists) {
await subtitlesDir.delete();
}
// 6. Clear MMKV caches like extracted image colors and other non-essential storage keys
const keysToKeep = [
"settings",
"serverUrl",
"token",
"user",
"deviceId",
"previousServers",
"hasAskedForNotificationPermission",
"hasShownIntro",
"multiAccountMigrated",
"selectedTVServer",
"downloads.v2.json",
];
const allKeys = storage.getAllKeys();
for (const key of allKeys) {
if (!keysToKeep.includes(key)) {
storage.remove(key);
}
}
// 7. Increment cache version to force remount of components
setCacheVersion((v) => v + 1);
} catch (error) {
console.error("Failed to clear cache:", error);
Alert.alert(
t("home.settings.toasts.error_deleting_files", "Error"),
t(
"home.settings.storage.clear_all_cache_error_desc",
"An error occurred while clearing the cache.",
),
);
}
},
},
],
);
};
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default;
const currentCacheMode = settings.mpvCacheEnabled ?? "auto";
const currentVoDriver = settings.mpvVoDriver ?? "gpu-next";
const currentLanguage = settings.preferedLanguage;
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [
{
label: t("home.settings.audio.transcode_mode.auto"),
value: AudioTranscodeMode.Auto,
selected: currentAudioTranscode === AudioTranscodeMode.Auto,
},
{
label: t("home.settings.audio.transcode_mode.stereo"),
value: AudioTranscodeMode.ForceStereo,
selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo,
},
{
label: t("home.settings.audio.transcode_mode.5_1"),
value: AudioTranscodeMode.Allow51,
selected: currentAudioTranscode === AudioTranscodeMode.Allow51,
},
{
label: t("home.settings.audio.transcode_mode.passthrough"),
value: AudioTranscodeMode.AllowAll,
selected: currentAudioTranscode === AudioTranscodeMode.AllowAll,
},
],
[t, currentAudioTranscode],
);
// Subtitle mode options
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
() => [
{
label: t("home.settings.subtitles.modes.Default"),
value: SubtitlePlaybackMode.Default,
selected: currentSubtitleMode === SubtitlePlaybackMode.Default,
},
{
label: t("home.settings.subtitles.modes.Smart"),
value: SubtitlePlaybackMode.Smart,
selected: currentSubtitleMode === SubtitlePlaybackMode.Smart,
},
{
label: t("home.settings.subtitles.modes.OnlyForced"),
value: SubtitlePlaybackMode.OnlyForced,
selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced,
},
{
label: t("home.settings.subtitles.modes.Always"),
value: SubtitlePlaybackMode.Always,
selected: currentSubtitleMode === SubtitlePlaybackMode.Always,
},
{
label: t("home.settings.subtitles.modes.None"),
value: SubtitlePlaybackMode.None,
selected: currentSubtitleMode === SubtitlePlaybackMode.None,
},
],
[t, currentSubtitleMode],
);
// MPV alignment options
const alignXOptions: TVOptionItem<string>[] = useMemo(
() => [
{ label: "Left", value: "left", selected: currentAlignX === "left" },
{
label: "Center",
value: "center",
selected: currentAlignX === "center",
},
{ label: "Right", value: "right", selected: currentAlignX === "right" },
],
[currentAlignX],
);
const alignYOptions: TVOptionItem<string>[] = useMemo(
() => [
{ label: "Top", value: "top", selected: currentAlignY === "top" },
{
label: "Center",
value: "center",
selected: currentAlignY === "center",
},
{
label: "Bottom",
value: "bottom",
selected: currentAlignY === "bottom",
},
],
[currentAlignY],
);
// Cache mode options
const cacheModeOptions: TVOptionItem<MpvCacheMode>[] = useMemo(
() => [
{
label: t("home.settings.buffer.cache_auto"),
value: "auto",
selected: currentCacheMode === "auto",
},
{
label: t("home.settings.buffer.cache_yes"),
value: "yes",
selected: currentCacheMode === "yes",
},
{
label: t("home.settings.buffer.cache_no"),
value: "no",
selected: currentCacheMode === "no",
},
],
[t, currentCacheMode],
);
// VO driver options
const voDriverOptions: TVOptionItem<MpvVoDriver>[] = useMemo(
() => [
{
label: t("home.settings.vo_driver.gpu_next"),
value: "gpu-next",
selected: currentVoDriver === "gpu-next",
},
{
label: t("home.settings.vo_driver.gpu"),
value: "gpu",
selected: currentVoDriver === "gpu",
},
],
[t, currentVoDriver],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
{
label: t("home.settings.appearance.display_size_small"),
value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small,
},
{
label: t("home.settings.appearance.display_size_default"),
value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default,
},
{
label: t("home.settings.appearance.display_size_large"),
value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large,
},
{
label: t("home.settings.appearance.display_size_extra_large"),
value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
},
],
[t, currentTypographyScale],
);
// Language options
const languageOptions: TVOptionItem<string | undefined>[] = useMemo(
() => [
{
label: t("home.settings.languages.system"),
value: undefined,
selected: !currentLanguage,
},
...APP_LANGUAGES.map((lang) => ({
label: lang.label,
value: lang.value,
selected: currentLanguage === lang.value,
})),
],
[t, currentLanguage],
);
// Inactivity timeout options (TV security feature)
const currentInactivityTimeout =
settings.inactivityTimeout ?? InactivityTimeout.Disabled;
const inactivityTimeoutOptions: TVOptionItem<InactivityTimeout>[] = useMemo(
() => [
{
label: t("home.settings.security.inactivity_timeout.disabled"),
value: InactivityTimeout.Disabled,
selected: currentInactivityTimeout === InactivityTimeout.Disabled,
},
{
label: t("home.settings.security.inactivity_timeout.1_minute"),
value: InactivityTimeout.OneMinute,
selected: currentInactivityTimeout === InactivityTimeout.OneMinute,
},
{
label: t("home.settings.security.inactivity_timeout.5_minutes"),
value: InactivityTimeout.FiveMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FiveMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.15_minutes"),
value: InactivityTimeout.FifteenMinutes,
selected: currentInactivityTimeout === InactivityTimeout.FifteenMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.30_minutes"),
value: InactivityTimeout.ThirtyMinutes,
selected: currentInactivityTimeout === InactivityTimeout.ThirtyMinutes,
},
{
label: t("home.settings.security.inactivity_timeout.1_hour"),
value: InactivityTimeout.OneHour,
selected: currentInactivityTimeout === InactivityTimeout.OneHour,
},
{
label: t("home.settings.security.inactivity_timeout.4_hours"),
value: InactivityTimeout.FourHours,
selected: currentInactivityTimeout === InactivityTimeout.FourHours,
},
{
label: t("home.settings.security.inactivity_timeout.24_hours"),
value: InactivityTimeout.TwentyFourHours,
selected:
currentInactivityTimeout === InactivityTimeout.TwentyFourHours,
},
],
[t, currentInactivityTimeout],
);
// Get display labels for option buttons
const audioTranscodeLabel = useMemo(() => {
const option = audioTranscodeModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.audio.transcode_mode.auto");
}, [audioTranscodeModeOptions, t]);
const subtitleModeLabel = useMemo(() => {
const option = subtitleModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.subtitles.modes.Default");
}, [subtitleModeOptions, t]);
const alignXLabel = useMemo(() => {
const option = alignXOptions.find((o) => o.selected);
return option?.label || "Center";
}, [alignXOptions]);
const alignYLabel = useMemo(() => {
const option = alignYOptions.find((o) => o.selected);
return option?.label || "Bottom";
}, [alignYOptions]);
const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.display_size_default");
}, [typographyScaleOptions, t]);
const cacheModeLabel = useMemo(() => {
const option = cacheModeOptions.find((o) => o.selected);
return option?.label || t("home.settings.buffer.cache_auto");
}, [cacheModeOptions, t]);
const voDriverLabel = useMemo(() => {
const option = voDriverOptions.find((o) => o.selected);
return option?.label || t("home.settings.vo_driver.gpu_next");
}, [voDriverOptions, t]);
const languageLabel = useMemo(() => {
if (!currentLanguage) return t("home.settings.languages.system");
const option = APP_LANGUAGES.find((l) => l.value === currentLanguage);
return option?.label || t("home.settings.languages.system");
}, [currentLanguage, t]);
const inactivityTimeoutLabel = useMemo(() => {
const option = inactivityTimeoutOptions.find((o) => o.selected);
return (
option?.label || t("home.settings.security.inactivity_timeout.disabled")
);
}, [inactivityTimeoutOptions, t]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 120,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Text
style={{
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("home.settings.settings_title")}
</Text>
{/* Account Section */}
<TVSectionHeader title={t("home.settings.switch_user.account")} />
<TVSettingsOptionButton
label={t("home.settings.switch_user.switch_user")}
value={user?.Name || "-"}
onPress={handleSwitchUser}
disabled={!hasOtherAccounts || isAnyModalOpen}
isFirst
/>
{/* Security Section */}
<TVSectionHeader title={t("home.settings.security.title")} />
<TVSettingsOptionButton
label={t("home.settings.security.inactivity_timeout.title")}
value={inactivityTimeoutLabel}
onPress={() =>
showOptions({
title: t("home.settings.security.inactivity_timeout.title"),
options: inactivityTimeoutOptions,
onSelect: (value) =>
updateSettings({ inactivityTimeout: value }),
})
}
/>
{/* Audio Section */}
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
onPress={() =>
showOptions({
title: t("home.settings.audio.transcode_mode.title"),
options: audioTranscodeModeOptions,
onSelect: (value) =>
updateSettings({ audioTranscodeMode: value }),
})
}
/>
{/* Subtitles Section */}
<TVSectionHeader
title={t("home.settings.subtitles.subtitle_title")}
/>
<TVSettingsOptionButton
label={t("home.settings.subtitles.subtitle_mode")}
value={subtitleModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.subtitles.subtitle_mode"),
options: subtitleModeOptions,
onSelect: (value) => updateSettings({ subtitleMode: value }),
})
}
/>
<TVSettingsToggle
label={t("home.settings.subtitles.set_subtitle_track")}
value={settings.rememberSubtitleSelections}
onToggle={(value) =>
updateSettings({ rememberSubtitleSelections: value })
}
/>
<TVSettingsStepper
label={t("home.settings.subtitles.subtitle_size")}
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
const newValue = Math.max(
0.1,
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
onIncrease={() => {
const newValue = Math.min(
3.0,
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
<TVSettingsStepper
label='Vertical Margin'
value={settings.mpvSubtitleMarginY ?? 0}
onDecrease={() => {
const newValue = Math.max(
0,
(settings.mpvSubtitleMarginY ?? 0) - 5,
);
updateSettings({ mpvSubtitleMarginY: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
100,
(settings.mpvSubtitleMarginY ?? 0) + 5,
);
updateSettings({ mpvSubtitleMarginY: newValue });
}}
/>
<TVSettingsOptionButton
label='Horizontal Alignment'
value={alignXLabel}
onPress={() =>
showOptions({
title: "Horizontal Alignment",
options: alignXOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignX: value as "left" | "center" | "right",
}),
})
}
/>
<TVSettingsOptionButton
label='Vertical Alignment'
value={alignYLabel}
onPress={() =>
showOptions({
title: "Vertical Alignment",
options: alignYOptions,
onSelect: (value) =>
updateSettings({
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
}),
})
}
/>
{/* OpenSubtitles Section */}
<TVSectionHeader
title={
t("home.settings.subtitles.opensubtitles_title") ||
"OpenSubtitles"
}
/>
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.subtitles.opensubtitles_hint") ||
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
}
value={openSubtitlesApiKey}
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => updateSettings({ openSubtitlesApiKey })}
secureTextEntry
/>
<Text
style={{
color: "#6B7280",
fontSize: typography.callout - 4,
marginTop: 8,
marginLeft: 8,
}}
>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
{/* Buffer Settings Section */}
<TVSectionHeader title={t("home.settings.buffer.title")} />
<TVSettingsOptionButton
label={t("home.settings.buffer.cache_mode")}
value={cacheModeLabel}
onPress={() =>
showOptions({
title: t("home.settings.buffer.cache_mode"),
options: cacheModeOptions,
onSelect: (value) => updateSettings({ mpvCacheEnabled: value }),
})
}
/>
{/* Video Output Section */}
<TVSectionHeader title={t("home.settings.vo_driver.title")} />
<TVSettingsOptionButton
label={t("home.settings.vo_driver.vo_mode")}
value={voDriverLabel}
onPress={() =>
showOptions({
title: t("home.settings.vo_driver.vo_mode"),
options: voDriverOptions,
onSelect: (value) => updateSettings({ mpvVoDriver: value }),
})
}
/>
<TVSettingsStepper
label={t("home.settings.buffer.buffer_duration")}
value={settings.mpvCacheSeconds ?? 10}
onDecrease={() => {
const newValue = Math.max(
5,
(settings.mpvCacheSeconds ?? 10) - 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
120,
(settings.mpvCacheSeconds ?? 10) + 5,
);
updateSettings({ mpvCacheSeconds: newValue });
}}
formatValue={(v) => `${v}s`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_cache_size")}
value={settings.mpvDemuxerMaxBytes ?? 150}
onDecrease={() => {
const newValue = Math.max(
50,
(settings.mpvDemuxerMaxBytes ?? 150) - 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
500,
(settings.mpvDemuxerMaxBytes ?? 150) + 25,
);
updateSettings({ mpvDemuxerMaxBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
<TVSettingsStepper
label={t("home.settings.buffer.max_backward_cache")}
value={settings.mpvDemuxerMaxBackBytes ?? 50}
onDecrease={() => {
const newValue = Math.max(
25,
(settings.mpvDemuxerMaxBackBytes ?? 50) - 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
onIncrease={() => {
const newValue = Math.min(
200,
(settings.mpvDemuxerMaxBackBytes ?? 50) + 25,
);
updateSettings({ mpvDemuxerMaxBackBytes: newValue });
}}
formatValue={(v) => `${v} MB`}
/>
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton
label={t("home.settings.appearance.display_size")}
value={typographyScaleLabel}
onPress={() =>
showOptions({
title: t("home.settings.appearance.display_size"),
options: typographyScaleOptions,
onSelect: (value) =>
updateSettings({ tvTypographyScale: value }),
})
}
/>
<TVSettingsOptionButton
label={t("home.settings.languages.app_language")}
value={languageLabel}
onPress={() =>
showOptions({
title: t("home.settings.languages.app_language"),
options: languageOptions,
onSelect: (value) =>
updateSettings({ preferedLanguage: value }),
})
}
/>
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
)}
value={settings.mergeNextUpAndContinueWatching}
onToggle={(value) =>
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_hero_carousel")}
value={settings.showTVHeroCarousel}
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_series_poster_on_episode")}
value={settings.showSeriesPosterOnEpisode}
onToggle={(value) =>
updateSettings({ showSeriesPosterOnEpisode: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.theme_music")}
value={settings.tvThemeMusicEnabled}
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/>
{/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton
label={t("home.settings.storage.clear_all_cache")}
value=''
onPress={handleClearCache}
isFirst
/>
{/* User Section */}
<TVSectionHeader
title={t("home.settings.user_info.user_info_title")}
/>
<TVSettingsRow
label={t("home.settings.user_info.user")}
value={user?.Name || "-"}
showChevron={false}
/>
<TVSettingsRow
label={t("home.settings.user_info.server")}
value={api?.basePath || "-"}
showChevron={false}
/>
{/* Logout Button */}
<View style={{ marginTop: 48, alignItems: "center" }}>
<TVLogoutButton onPress={logout} />
</View>
</ScrollView>
</View>
{/* PIN Entry Modal */}
<TVPINEntryModal
visible={pinModalVisible}
onClose={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSuccess={handlePinSuccess}
onForgotPIN={() => {
setPinModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
serverUrl={selectedServer?.address || ""}
userId={selectedAccount?.userId || ""}
username={selectedAccount?.username || ""}
/>
{/* Password Entry Modal */}
<TVPasswordEntryModal
visible={passwordModalVisible}
onClose={() => {
setPasswordModalVisible(false);
setSelectedAccount(null);
setSelectedServer(null);
}}
onSubmit={handlePasswordSubmit}
username={selectedAccount?.username || ""}
/>
</View>
);
}

View File

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

View File

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

View File

@@ -88,7 +88,12 @@ export default function Page() {
}, [share, loading]); }, [share, loading]);
return ( return (
<View className='flex-1'> <View
className='flex-1'
style={{
paddingTop: insets.top + 48,
}}
>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton <FilterButton
id={orderFilterId} id={orderFilterId}
@@ -112,10 +117,7 @@ export default function Page() {
multiple={true} multiple={true}
/> />
</View> </View>
<ScrollView <ScrollView className='pb-4 px-4'>
className='pb-4 px-4'
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
{filteredLogs?.map((log, index) => ( {filteredLogs?.map((log, index) => (
<View className='bg-neutral-900 rounded-xl p-3' key={index}> <View className='bg-neutral-900 rounded-xl p-3' key={index}>

View File

@@ -3,8 +3,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { GestureControls } from "@/components/settings/GestureControls"; import { GestureControls } from "@/components/settings/GestureControls";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
import { MpvBufferSettings } from "@/components/settings/MpvBufferSettings";
import { MpvVoSettings } from "@/components/settings/MpvVoSettings";
import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings"; import { PlaybackControlsSettings } from "@/components/settings/PlaybackControlsSettings";
import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings"; import { ChromecastSettings } from "../../../../../../components/settings/ChromecastSettings";
@@ -28,8 +26,6 @@ export default function PlaybackControlsPage() {
<MediaToggles className='mb-4' /> <MediaToggles className='mb-4' />
<GestureControls className='mb-4' /> <GestureControls className='mb-4' />
<PlaybackControlsSettings /> <PlaybackControlsSettings />
<MpvBufferSettings />
<MpvVoSettings />
</MediaProvider> </MediaProvider>
</View> </View>
{!Platform.isTV && <ChromecastSettings />} {!Platform.isTV && <ChromecastSettings />}

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr"; import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function Page() {
const { pluginSettings } = useSettings(); const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -18,7 +18,7 @@ export default function page() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true} disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4' className='px-4'
> >
<JellyseerrSettings /> <JellyseerrSettings />
</DisabledSetting> </DisabledSetting>

View File

@@ -4,7 +4,7 @@ import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks"; import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function Page() {
const { pluginSettings } = useSettings(); const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -18,7 +18,7 @@ export default function page() {
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true} disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4' className='px-4'
> >
<KefinTweaksSettings /> <KefinTweaksSettings />
</DisabledSetting> </DisabledSetting>

View File

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

View File

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

View File

@@ -15,24 +15,14 @@ import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, Platform, useWindowDimensions, View } from "react-native"; import { FlatList, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
@@ -46,29 +36,19 @@ import {
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const page: React.FC = () => { const page: React.FC = () => {
const searchParams = useLocalSearchParams(); const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string }; const { collectionId } = searchParams as { collectionId: string };
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState( const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP, ScreenOrientation.Orientation.PORTRAIT_UP,
); );
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -76,7 +56,7 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection, isLoading: isCollectionLoading } = useQuery({ const { data: collection } = useQuery({
queryKey: ["collection", collectionId], queryKey: ["collection", collectionId],
queryFn: async () => { queryFn: async () => {
if (!api) return null; if (!api) return null;
@@ -91,46 +71,6 @@ const page: React.FC = () => {
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
useEffect(() => { useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" }); navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]); setSortOrder([SortOrderOption.Ascending]);
@@ -147,18 +87,6 @@ const page: React.FC = () => {
setSortBy([sortByOption]); setSortBy([sortByOption]);
}, [navigation, collection]); }, [navigation, collection]);
// Calculate columns for TV grid
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
const itemWidth = posterSizes.poster + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
}, [screenWidth, orientation]);
const fetchItems = useCallback( const fetchItems = useCallback(
async ({ async ({
pageParam, pageParam,
@@ -170,7 +98,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user?.Id, userId: user?.Id,
parentId: collectionId, parentId: collectionId,
limit: Platform.isTV ? 36 : 18, limit: 18,
startIndex: pageParam, startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple. // Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]], sortBy: [sortBy[0]],
@@ -186,7 +114,7 @@ const page: React.FC = () => {
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: ["Movie", "Series", "Season"], includeItemTypes: ["Movie", "Series"],
}); });
return response.data || null; return response.data || null;
@@ -195,7 +123,6 @@ const page: React.FC = () => {
api, api,
user?.Id, user?.Id,
collection, collection,
collectionId,
selectedGenres, selectedGenres,
selectedYears, selectedYears,
selectedTags, selectedTags,
@@ -204,40 +131,39 @@ const page: React.FC = () => {
], ],
); );
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
useInfiniteQuery({ queryKey: [
queryKey: [ "collection-items",
"collection-items", collection,
collectionId, selectedGenres,
selectedGenres, selectedYears,
selectedYears, selectedTags,
selectedTags, sortBy,
sortBy, sortOrder,
sortOrder, ],
], queryFn: fetchItems,
queryFn: fetchItems, getNextPageParam: (lastPage, pages) => {
getNextPageParam: (lastPage, pages) => { if (
if ( !lastPage?.Items ||
!lastPage?.Items || !lastPage?.TotalRecordCount ||
!lastPage?.TotalRecordCount || lastPage?.TotalRecordCount === 0
lastPage?.TotalRecordCount === 0 )
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined; return undefined;
},
initialPageParam: 0, const totalItems = lastPage.TotalRecordCount;
enabled: !!api && !!user?.Id && !!collection, const accumulatedItems = pages.reduce(
}); (acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => { const flatData = useMemo(() => {
return ( return (
@@ -269,6 +195,7 @@ const page: React.FC = () => {
}} }}
> >
<ItemPoster item={item} /> <ItemPoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>
@@ -276,35 +203,10 @@ const page: React.FC = () => {
[orientation], [orientation],
); );
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(home)");
router.push(navTarget as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
}}
>
<TVPosterCard
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
</View>
);
},
[router, showItemActions, posterSizes.poster],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const _insets = useSafeAreaInsets();
const ListHeaderComponent = useCallback( const ListHeaderComponent = useCallback(
() => ( () => (
<FlatList <FlatList
@@ -470,315 +372,48 @@ const page: React.FC = () => {
], ],
); );
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
// TV filter bar state
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
if (isLoading || isCollectionLoading) {
return (
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
}
if (!collection) return null; if (!collection) return null;
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return ( return (
<View style={{ flex: 1 }}> <FlashList
{/* Filter bar */} ListEmptyComponent={
<View <View className='flex flex-col items-center justify-center h-full'>
style={{ <Text className='font-bold text-xl text-neutral-500'>
flexDirection: "row", {t("search.no_results")}
flexWrap: "nowrap", </Text>
marginTop: insets.top + 100, </View>
paddingBottom: 8, }
paddingHorizontal: TV_SCALE_PADDING, extraData={[
gap: 12, selectedGenres,
}} selectedYears,
> selectedTags,
{hasActiveFilters && ( sortBy,
<TVFilterButton sortOrder,
label='' ]}
value={t("library.filters.reset")} contentInsetAdjustmentBehavior='automatic'
onPress={resetAllFilters} data={flatData}
hasActiveFilter renderItem={renderItem}
/> keyExtractor={keyExtractor}
)} numColumns={
<TVFilterButton orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
label={t("library.filters.genres")} }
value={ onEndReached={() => {
selectedGenres.length > 0 if (hasNextPage) {
? `${selectedGenres.length} selected` fetchNextPage();
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
</View>
{/* Grid */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
} }
contentInsetAdjustmentBehavior='automatic' }}
data={flatData} onEndReachedThreshold={0.5}
renderItem={renderTVItem} ListHeaderComponent={ListHeaderComponent}
extraData={[orientation, nrOfCols]} contentContainerStyle={{ paddingBottom: 24 }}
keyExtractor={keyExtractor} ItemSeparatorComponent={() => (
numColumns={nrOfCols} <View
removeClippedSubviews={false} style={{
onEndReached={() => { width: 10,
if (hasNextPage) { height: 10,
fetchNextPage(); }}
} />
}} )}
onEndReachedThreshold={1} />
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
); );
}; };

View File

@@ -3,8 +3,9 @@ import { useLocalSearchParams } from "expo-router";
import type React from "react"; import type React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { View } from "react-native";
import Animated, { import Animated, {
runOnJS,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@@ -14,10 +15,6 @@ import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery"; import { useItemQuery } from "@/hooks/useItemQuery";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
const ItemContentSkeletonTV = Platform.isTV
? require("@/components/ItemContentSkeleton.tv").ItemContentSkeletonTV
: null;
const Page: React.FC = () => { const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation(); const { t } = useTranslation();
@@ -27,11 +24,7 @@ const Page: React.FC = () => {
// Exclude MediaSources/MediaStreams from initial fetch for faster loading // Exclude MediaSources/MediaStreams from initial fetch for faster loading
// (especially important for plugins like Gelato) // (especially important for plugins like Gelato)
const { const { data: item, isError } = useItemQuery(id, isOffline, undefined, [
data: item,
isError,
isLoading,
} = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources, ItemFields.MediaSources,
ItemFields.MediaSourceCount, ItemFields.MediaSourceCount,
ItemFields.MediaStreams, ItemFields.MediaStreams,
@@ -47,14 +40,33 @@ const Page: React.FC = () => {
}; };
}); });
// Fast fade out when item loads (no setTimeout delay) const fadeOut = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
const fadeIn = (callback: any) => {
setTimeout(() => {
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
}, 100);
};
useEffect(() => { useEffect(() => {
if (item) { if (item) {
opacity.value = withTiming(0, { duration: 150 }); fadeOut(() => {});
} else { } else {
opacity.value = withTiming(1, { duration: 150 }); fadeIn(() => {});
} }
}, [item, opacity]); }, [item]);
if (isError) if (isError)
return ( return (
@@ -66,46 +78,31 @@ const Page: React.FC = () => {
return ( return (
<OfflineModeProvider isOffline={isOffline}> <OfflineModeProvider isOffline={isOffline}>
<View className='flex flex-1 relative'> <View className='flex flex-1 relative'>
{/* Always render ItemContent - it handles loading state internally on TV */} <Animated.View
<ItemContent pointerEvents={"none"}
item={item} style={[animatedStyle]}
itemWithSources={itemWithSources} className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
isLoading={isLoading} >
/> <View
style={{
{/* Skeleton overlay - fades out when content loads */} height: item?.Type === "Episode" ? 300 : 450,
{!item && ( }}
<Animated.View className='bg-transparent rounded-lg mb-4 w-full'
pointerEvents={"none"} />
style={[animatedStyle]} <View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black' <View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
> <View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
{Platform.isTV && ItemContentSkeletonTV ? ( <View className='flex flex-row space-x-1 mb-8'>
<ItemContentSkeletonTV /> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
) : ( <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View style={{ paddingHorizontal: 16, width: "100%" }}> <View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View </View>
style={{ <View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
height: 450, <View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
}} <View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
className='bg-transparent rounded-lg mb-4 w-full' <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
/> </Animated.View>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' /> {item && <ItemContent item={item} itemWithSources={itemWithSources} />}
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
</View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View>
)}
</Animated.View>
)}
</View> </View>
</OfflineModeProvider> </OfflineModeProvider>
); );

View File

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

View File

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

View File

@@ -21,7 +21,6 @@ import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast"; import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts"; import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
@@ -53,8 +52,7 @@ import type {
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
// Mobile page component const Page: React.FC = () => {
const MobilePage: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -544,12 +542,4 @@ const MobilePage: React.FC = () => {
); );
}; };
// Platform-conditional page component
const Page: React.FC = () => {
if (Platform.isTV) {
return <TVJellyseerrPage />;
}
return <MobilePage />;
};
export default Page; export default Page;

View File

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

View File

@@ -7,8 +7,7 @@ import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { Slot, Stack, withLayoutContext } from "expo-router"; import { Stack, withLayoutContext } from "expo-router";
import { Platform } from "react-native";
const { Navigator } = createMaterialTopTabNavigator(); const { Navigator } = createMaterialTopTabNavigator();
@@ -20,17 +19,6 @@ export const Tab = withLayoutContext<
>(Navigator); >(Navigator);
const Layout = () => { const Layout = () => {
// On TV, skip the Material Top Tab Navigator and render children directly
// The TV version handles its own tab navigation internally
if (Platform.isTV) {
return (
<>
<Stack.Screen options={{ headerShown: false }} />
<Slot />
</>
);
}
return ( return (
<> <>
<Stack.Screen options={{ title: "Live TV" }} /> <Stack.Screen options={{ title: "Live TV" }} />

View File

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

View File

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

View File

@@ -2,21 +2,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TVLiveTVPage } from "@/components/livetv/TVLiveTVPage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() { export default function Page() {
if (Platform.isTV) {
return <TVLiveTVPage />;
}
return <MobileLiveTVPrograms />;
}
function MobileLiveTVPrograms() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();

View File

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

View File

@@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll"; import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -15,7 +15,6 @@ import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { TVActorPage } from "@/components/persons/TVActorPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -24,16 +23,6 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
// Render TV-optimized page on TV platforms
if (Platform.isTV) {
return <TVActorPage personId={personId} />;
}
return <MobileActorPage personId={personId} />;
};
const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);

View File

@@ -14,7 +14,6 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
import { NextUp } from "@/components/series/NextUp"; import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker"; import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesHeader } from "@/components/series/SeriesHeader"; import { SeriesHeader } from "@/components/series/SeriesHeader";
import { TVSeriesPage } from "@/components/series/TVSeriesPage";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
@@ -62,7 +61,6 @@ const page: React.FC = () => {
}); });
}, },
staleTime: isOffline ? Infinity : 60 * 1000, staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id), enabled: isOffline || (!!api && !!user?.Id),
}); });
@@ -118,8 +116,7 @@ const page: React.FC = () => {
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) || (a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0), (a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
), ),
staleTime: isOffline ? Infinity : 60 * 1000, staleTime: isOffline ? Infinity : 60,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id), enabled: isOffline || (!!api && !!user?.Id),
}); });
@@ -162,19 +159,6 @@ const page: React.FC = () => {
// For offline mode, we can show the page even without backdropUrl // For offline mode, we can show the page even without backdropUrl
if (!item || (!isOffline && !backdropUrl)) return null; if (!item || (!isOffline && !backdropUrl)) return null;
// TV version
if (Platform.isTV) {
return (
<OfflineModeProvider isOffline={isOffline}>
<TVSeriesPage
item={item}
allEpisodes={allEpisodes}
isLoading={isLoading}
/>
</OfflineModeProvider>
);
}
return ( return (
<OfflineModeProvider isOffline={isOffline}> <OfflineModeProvider isOffline={isOffline}>
<ParallaxScrollView <ParallaxScrollView

View File

@@ -11,37 +11,20 @@ import {
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { FlatList, useWindowDimensions, View } from "react-native";
FlatList,
Platform,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVFilterButton, TVFocusablePoster } from "@/components/tv";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
@@ -65,13 +48,6 @@ import {
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20;
const TV_PLAYLIST_SQUARE_SIZE = 180;
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams() as { const searchParams = useLocalSearchParams() as {
@@ -82,8 +58,6 @@ const Page = () => {
}; };
const { libraryId } = searchParams; const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
@@ -105,49 +79,6 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
});
useEffect(() => { useEffect(() => {
// Check for URL params first (from "See All" navigation) // Check for URL params first (from "See All" navigation)
@@ -231,10 +162,6 @@ const Page = () => {
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2; if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3; if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5; if (screenWidth < 800) return 5;
@@ -286,8 +213,6 @@ const Page = () => {
itemType = "Video"; itemType = "Video";
} else if (library.CollectionType === "musicvideos") { } else if (library.CollectionType === "musicvideos") {
itemType = "MusicVideo"; itemType = "MusicVideo";
} else if (library.CollectionType === "playlists") {
itemType = "Playlist";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -307,9 +232,6 @@ const Page = () => {
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year, 10)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
...(Platform.isTV && library.CollectionType === "playlists"
? { mediaTypes: ["Video"] }
: {}),
}); });
return response.data || null; return response.data || null;
@@ -400,88 +322,7 @@ const Page = () => {
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>
), ),
[orientation, nrOfCols], [orientation],
);
const renderTVItem = useCallback(
(item: BaseItemDto) => {
const handlePress = () => {
if (item.Type === "Playlist") {
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: { libraryId: item.Id! },
});
return;
}
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
// Special rendering for Playlist items (square thumbnails)
if (item.Type === "Playlist") {
const playlistImageUrl = getPrimaryImageUrl({
api,
item,
width: TV_PLAYLIST_SQUARE_SIZE * 2,
});
return (
<View
key={item.Id}
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
alignItems: "center",
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
<View
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
aspectRatio: 1,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, alignItems: "center" }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
return (
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, api, typography],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -668,188 +509,6 @@ const Page = () => {
], ],
); );
// TV Filter bar header
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0 ||
filterBy.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
_setFilterBy([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
const tvFilterByOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: filterBy.length === 0,
},
...generalFilters.map((option) => ({
label: option.value,
value: option.key,
selected: filterBy.includes(option.key),
})),
],
[filterBy, generalFilters, t],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
const handleShowFilterByFilter = useCallback(() => {
showOptions({
title: t("library.filters.filter_by"),
options: tvFilterByOptions,
onSelect: (value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
});
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading) if (isLoading || isLibraryLoading)
@@ -859,176 +518,43 @@ const Page = () => {
</View> </View>
); );
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
key={orientation}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return ( return (
<ScrollView <FlashList
style={{ flex: 1 }} key={orientation}
contentContainerStyle={{ ListEmptyComponent={
paddingTop: insets.top + 100, <View className='flex flex-col items-center justify-center h-full'>
paddingBottom: insets.bottom + 60, <Text className='font-bold text-xl text-neutral-500'>
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
onScroll={({ nativeEvent }) => {
// Load more when near bottom
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isNearBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - 500;
if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={handleShowFilterByFilter}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_results")} {t("library.no_results")}
</Text> </Text>
</View> </View>
) : ( }
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
ItemSeparatorComponent={() => (
<View <View
style={{ style={{
flexDirection: "row", width: 10,
flexWrap: "wrap", height: 10,
justifyContent: "center",
gap: TV_ITEM_GAP,
}} }}
> />
{flatData.map((item) => renderTVItem(item))}
</View>
)} )}
/>
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
); );
}; };

View File

@@ -1,11 +1,109 @@
import { Platform } from "react-native"; import {
import { Libraries } from "@/components/library/Libraries"; getUserLibraryApi,
import { TVLibraries } from "@/components/library/TVLibraries"; getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
export default function LibrariesPage() { export default function Index() {
if (Platform.isTV) { const [api] = useAtom(apiAtom);
return <TVLibraries />; const [user] = useAtom(userAtom);
} const queryClient = useQueryClient();
const { settings } = useSettings();
return <Libraries />; const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
staleTime: 60,
});
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
queryKey: ["library", item.Id],
queryFn: async () => {
if (!item.Id || !user?.Id || !api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
userId: user?.Id,
});
return response.data;
},
staleTime: 60 * 1000,
});
}
}, [data]);
const insets = useSafeAreaInsets();
if (isLoading)
return (
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: Platform.OS === "android" ? 17 : 0,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
paddingBottom: 150,
paddingLeft: insets.left + 17,
paddingRight: insets.right + 17,
}}
data={libraries}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
settings?.libraryOptions?.display === "row" ? (
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className='bg-neutral-800 mx-2 my-4'
/>
) : (
<View className='h-4' />
)
}
/>
);
} }

View File

@@ -7,9 +7,8 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -23,11 +22,9 @@ import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { import {
JellyseerrSearchSort, JellyseerrSearchSort,
@@ -39,20 +36,12 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { createStreamystatsApi } from "@/utils/streamystats"; import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -66,13 +55,10 @@ const exampleSearches = [
"The Mandalorian", "The Mandalorian",
]; ];
export default function search() { export default function Search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -213,7 +199,9 @@ export default function search() {
return []; return [];
} }
const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types const url = `${
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type)) .map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
@@ -305,9 +293,6 @@ export default function search() {
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: false, autoFocus: false,
// Android: placeholder and icon color
hintTextColor: "#fff",
headerIconColor: "#fff",
}, },
}); });
}, [navigation]); }, [navigation]);
@@ -455,180 +440,6 @@ export default function search() {
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12; return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]); }, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
// TV item press handler
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
// Jellyseerr search for TV
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
useQuery({
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
queryFn: async () => {
const params = {
query: new URLSearchParams(debouncedSearch || "").toString(),
};
return await Promise.all([
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
"id",
),
);
},
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
// Process Jellyseerr results for TV
const jellyseerrMovieResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTvResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrPersonResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTVNoResults = useMemo(() => {
return (
!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length
);
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
// Fetch discover settings for TV (when no search query in Discover mode)
const { data: discoverSliders } = useQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length === 0,
});
// TV Jellyseerr press handlers
const handleJellyseerrMoviePress = useCallback(
(item: MovieResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.title,
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.MOVIE,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrTvPress = useCallback(
(item: TvResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.name,
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.TV,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrPersonPress = useCallback(
(item: PersonResult) => {
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
},
[router],
);
// Render TV search page
if (Platform.isTV) {
return (
<TVSearchPage
search={search}
setSearch={setSearch}
debouncedSearch={debouncedSearch}
movies={movies}
series={series}
episodes={episodes}
collections={collections}
actors={actors}
artists={artists}
albums={albums}
songs={songs}
playlists={playlists}
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType}
setSearchType={setSearchType}
showDiscover={!!jellyseerrApi}
jellyseerrMovies={jellyseerrMovieResults}
jellyseerrTv={jellyseerrTvResults}
jellyseerrPersons={jellyseerrPersonResults}
jellyseerrLoading={jellyseerrTVLoading}
jellyseerrNoResults={jellyseerrTVNoResults}
onJellyseerrMoviePress={handleJellyseerrMoviePress}
onJellyseerrTvPress={handleJellyseerrTvPress}
onJellyseerrPersonPress={handleJellyseerrPersonPress}
discoverSliders={discoverSliders}
/>
);
}
return ( return (
<ScrollView <ScrollView
keyboardDismissMode='on-drag' keyboardDismissMode='on-drag'
@@ -639,6 +450,26 @@ export default function search() {
paddingBottom: 60, paddingBottom: 60,
}} }}
> >
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View <View
className='flex flex-col' className='flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }} style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
@@ -941,7 +772,7 @@ export default function search() {
</Text> </Text>
</View> </View>
) : debouncedSearch.length === 0 ? ( ) : debouncedSearch.length === 0 ? (
<View className='mt-2 flex flex-col items-center space-y-2'> <View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => ( {exampleSearches.map((e) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {

View File

@@ -1,21 +0,0 @@
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SettingsLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name='index'
options={{
headerShown: !Platform.isTV,
headerTitle: t("tabs.settings"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -1,5 +0,0 @@
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
export default function SettingsTabScreen() {
return <SettingsTV />;
}

View File

@@ -8,9 +8,7 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Platform,
RefreshControl, RefreshControl,
ScrollView,
TouchableOpacity, TouchableOpacity,
useWindowDimensions, useWindowDimensions,
View, View,
@@ -18,18 +16,11 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { TVPosterCard } from "@/components/tv/TVPosterCard";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { import {
useDeleteWatchlist, useDeleteWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,
@@ -41,15 +32,9 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
export default function WatchlistDetailScreen() { export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
@@ -62,8 +47,6 @@ export default function WatchlistDetailScreen() {
: undefined; : undefined;
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
// TV uses flexWrap, so nrOfCols is just for mobile
if (Platform.isTV) return 1;
if (screenWidth < 300) return 2; if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3; if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5; if (screenWidth < 800) return 5;
@@ -170,28 +153,6 @@ export default function WatchlistDetailScreen() {
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t], [removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
); );
const renderTVItem = useCallback(
(item: BaseItemDto, index: number) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
};
return (
<TVPosterCard
key={item.Id}
item={item}
orientation='vertical'
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
width={posterSizes.poster}
/>
);
},
[router, showItemActions, posterSizes.poster],
);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => ( ({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter <TouchableItemRouter
@@ -305,120 +266,6 @@ export default function WatchlistDetailScreen() {
); );
} }
// TV layout with ScrollView + flexWrap
if (Platform.isTV) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
>
{/* Header */}
<View
style={{
alignItems: "center",
marginBottom: 32,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
}}
>
{watchlist.description && (
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
}}
>
{watchlist.description}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 24,
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={20}
color='#9ca3af'
/>
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
{/* Grid with flexWrap */}
{!items || items.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,
}}
>
{t("watchlists.empty_watchlist")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{items.map((item, index) => renderTVItem(item, index))}
</View>
)}
</ScrollView>
);
}
// Mobile layout with FlashList
return ( return (
<FlashList <FlashList
key={orientation} key={orientation}

View File

@@ -40,7 +40,7 @@ export default function WatchlistsLayout() {
name='[watchlistId]' name='[watchlistId]'
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -51,7 +51,7 @@ export default function WatchlistsLayout() {
options={{ options={{
title: t("watchlists.create_title"), title: t("watchlists.create_title"),
presentation: "modal", presentation: "modal",
headerShown: !Platform.isTV, headerShown: true,
headerStyle: { backgroundColor: "#171717" }, headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white", headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" }, contentStyle: { backgroundColor: "#171717" },
@@ -62,7 +62,7 @@ export default function WatchlistsLayout() {
options={{ options={{
title: t("watchlists.edit_title"), title: t("watchlists.edit_title"),
presentation: "modal", presentation: "modal",
headerShown: !Platform.isTV, headerShown: true,
headerStyle: { backgroundColor: "#171717" }, headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white", headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" }, contentStyle: { backgroundColor: "#171717" },

View File

@@ -11,19 +11,12 @@ import { withLayoutContext } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useTVHomeBackHandler } from "@/hooks/useTVBackHandler";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
// Music components are not available on tvOS (TrackPlayer not supported)
const MiniPlayerBar = Platform.isTV
? () => null
: require("@/components/music/MiniPlayerBar").MiniPlayerBar;
const MusicPlaybackEngine = Platform.isTV
? () => null
: require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
@@ -37,9 +30,6 @@ export default function TabLayout() {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
// Handle TV back button - prevent app exit when at root
useTVHomeBackHandler();
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />
@@ -127,17 +117,6 @@ export default function TabLayout() {
: (_e) => ({ sfSymbol: "list.dash.fill" }), : (_e) => ({ sfSymbol: "list.dash.fill" }),
}} }}
/> />
<NativeTabs.Screen
name='(settings)'
options={{
title: t("tabs.settings"),
tabBarItemHidden: !Platform.isTV,
tabBarIcon:
Platform.OS === "android"
? (_e) => require("@/assets/icons/gear.png") //Should maybe use other libraries to have it uniform
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>
</NativeTabs> </NativeTabs>
<MiniPlayerBar /> <MiniPlayerBar />
<MusicPlaybackEngine /> <MusicPlaybackEngine />

View File

@@ -1,7 +1,6 @@
import { import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
type MediaStream,
PlaybackOrder, PlaybackOrder,
PlaybackProgressInfo, PlaybackProgressInfo,
RepeatMode, RepeatMode,
@@ -10,7 +9,6 @@ import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { File } from "expo-file-system";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
@@ -22,7 +20,6 @@ import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { Controls as TVControls } from "@/components/video-player/controls/Controls.tv";
import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext"; import { PlayerProvider } from "@/components/video-player/controls/contexts/PlayerContext";
import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext"; import { VideoProvider } from "@/components/video-player/controls/contexts/VideoContext";
import { import {
@@ -46,13 +43,11 @@ import {
} from "@/modules"; } from "@/modules";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useInactivity } from "@/providers/InactivityProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { getSubtitlesForItem } from "@/utils/atoms/downloadedSubtitles";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
@@ -60,10 +55,10 @@ import {
getMpvSubtitleId, getMpvSubtitleId,
} from "@/utils/jellyfin/subtitleUtils"; } from "@/utils/jellyfin/subtitleUtils";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { generateDeviceProfile } from "../../../utils/profiles/native";
export default function page() { export default function Page() {
const videoRef = useRef<MpvPlayerViewRef>(null); const videoRef = useRef<MpvPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -90,12 +85,6 @@ export default function page() {
const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0); const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1.0);
const [showTechnicalInfo, setShowTechnicalInfo] = useState(false); const [showTechnicalInfo, setShowTechnicalInfo] = useState(false);
// TV audio/subtitle selection state (tracks current selection for dynamic changes)
const [currentAudioIndex, setCurrentAudioIndex] = useState<
number | undefined
>(undefined);
const [currentSubtitleIndex, setCurrentSubtitleIndex] = useState<number>(-1);
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
@@ -108,9 +97,6 @@ export default function page() {
// when data updates, only when the provider initializes // when data updates, only when the provider initializes
const downloadedFiles = downloadUtils.getDownloadedItems(); const downloadedFiles = downloadUtils.getDownloadedItems();
// Inactivity timer controls (TV only)
const { pauseInactivityTimer, resumeInactivityTimer } = useInactivity();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
@@ -141,6 +127,7 @@ export default function page() {
const { lockOrientation, unlockOrientation } = useOrientation(); const { lockOrientation, unlockOrientation } = useOrientation();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager({ isOffline: offline });
// Audio index: use URL param if provided, otherwise use stored index for offline playback // Audio index: use URL param if provided, otherwise use stored index for offline playback
// This is computed after downloadedItem is available, see audioIndexResolved below // This is computed after downloadedItem is available, see audioIndexResolved below
@@ -155,13 +142,6 @@ export default function page() {
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const initialSeekDoneRef = useRef(false);
const initialPlaybackTicksRef = useRef<number>(
playbackPositionFromUrl
? Number.parseInt(playbackPositionFromUrl, 10)
: (item?.UserData?.PlaybackPositionTicks ?? 0),
);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>( const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null, null,
); );
@@ -170,10 +150,6 @@ export default function page() {
isError: false, isError: false,
}); });
// Playback manager for progress reporting and adjacent items
const playbackManager = usePlaybackManager({ item, isOffline: offline });
const { nextItem, previousItem } = playbackManager;
// Resolve audio index: use URL param if provided, otherwise use stored index for offline playback // Resolve audio index: use URL param if provided, otherwise use stored index for offline playback
const audioIndex = useMemo(() => { const audioIndex = useMemo(() => {
if (audioIndexFromUrl !== undefined) { if (audioIndexFromUrl !== undefined) {
@@ -185,17 +161,6 @@ export default function page() {
return undefined; return undefined;
}, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]);
// Initialize TV audio/subtitle indices from URL params
useEffect(() => {
if (audioIndex !== undefined) {
setCurrentAudioIndex(audioIndex);
}
}, [audioIndex]);
useEffect(() => {
setCurrentSubtitleIndex(subtitleIndex);
}, [subtitleIndex]);
// Get the playback speed for this item based on settings // Get the playback speed for this item based on settings
const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed(
item, item,
@@ -222,25 +187,12 @@ export default function page() {
); );
/** Gets the initial playback position from the URL. */ /** Gets the initial playback position from the URL. */
// const getInitialPlaybackTicks = useCallback((): number => { const getInitialPlaybackTicks = useCallback((): number => {
// if (playbackPositionFromUrl) { if (playbackPositionFromUrl) {
// return Number.parseInt(playbackPositionFromUrl, 10); return Number.parseInt(playbackPositionFromUrl, 10);
// }
// return item?.UserData?.PlaybackPositionTicks ?? 0;
// }, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => {
if (!tracksReady || !videoRef.current) return;
if (initialSeekDoneRef.current) return;
initialSeekDoneRef.current = true;
const ticks = initialPlaybackTicksRef.current;
if (ticks > 0) {
videoRef.current.seekTo(ticksToSeconds(ticks));
} }
}, [tracksReady]); return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl, item?.UserData?.PlaybackPositionTicks]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
@@ -254,12 +206,7 @@ export default function page() {
setDownloadedItem(data); setDownloadedItem(data);
} }
} else { } else {
// Guard against api being null (e.g., during logout) const res = await getUserLibraryApi(api!).getItem({
if (!api) {
setItemStatus({ isLoading: false, isError: false });
return;
}
const res = await getUserLibraryApi(api).getItem({
itemId, itemId,
userId: user?.Id, userId: user?.Id,
}); });
@@ -293,7 +240,6 @@ export default function page() {
mediaSource: MediaSourceInfo; mediaSource: MediaSourceInfo;
sessionId: string; sessionId: string;
url: string; url: string;
requiredHttpHeaders?: Record<string, string>;
} }
const [stream, setStream] = useState<Stream | null>(null); const [stream, setStream] = useState<Stream | null>(null);
@@ -302,18 +248,15 @@ export default function page() {
isError: false, isError: false,
}); });
// Ref to store the stream fetch function for refreshing subtitle tracks
const refetchStreamRef = useRef<(() => Promise<Stream | null>) | null>(null);
useEffect(() => { useEffect(() => {
const fetchStreamData = async (): Promise<Stream | null> => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
try { try {
// Don't attempt to fetch stream data if item is not available // Don't attempt to fetch stream data if item is not available
if (!item?.Id) { if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch"); console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
return null; return;
} }
let result: Stream | null = null; let result: Stream | null = null;
@@ -331,12 +274,12 @@ export default function page() {
if (!api) { if (!api) {
console.warn("API not available for streaming"); console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
return null; return;
} }
if (!user?.Id) { if (!user?.Id) {
console.warn("User not authenticated for streaming"); console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
return null; return;
} }
// Calculate start ticks directly from item to avoid stale closure // Calculate start ticks directly from item to avoid stale closure
@@ -355,30 +298,25 @@ export default function page() {
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(), deviceProfile: generateDeviceProfile(),
}); });
if (!res) return null; if (!res) return;
const { mediaSource, sessionId, url, requiredHttpHeaders } = res; const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) { if (!sessionId || !mediaSource || !url) {
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.failed_to_get_stream_url"), t("player.failed_to_get_stream_url"),
); );
return null; return;
} }
result = { mediaSource, sessionId, url, requiredHttpHeaders }; result = { mediaSource, sessionId, url };
} }
setStream(result); setStream(result);
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
return result;
} catch (error) { } catch (error) {
console.error("Failed to fetch stream:", error); console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true }); setStreamStatus({ isLoading: false, isError: true });
return null;
} }
}; };
// Store the fetch function in ref for use by refresh handler
refetchStreamRef.current = fetchStreamData;
fetchStreamData(); fetchStreamData();
}, [ }, [
itemId, itemId,
@@ -452,9 +390,7 @@ export default function page() {
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.pause(); videoRef.current?.pause();
revalidateProgressCache(); revalidateProgressCache();
// Resume inactivity timer when leaving player (TV only) }, [videoRef, reportPlaybackStopped, progress]);
resumeInactivityTimer();
}, [videoRef, reportPlaybackStopped, progress, resumeInactivityTimer]);
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -564,6 +500,11 @@ export default function page() {
], ],
); );
/** Gets the initial playback position in seconds. */
const _startPosition = useMemo(() => {
return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]);
/** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */ /** Prepare metadata for iOS native media controls (Control Center, Lock Screen) */
const nowPlayingMetadata = useMemo(() => { const nowPlayingMetadata = useMemo(() => {
if (!item || !api) return undefined; if (!item || !api) return undefined;
@@ -641,15 +582,6 @@ export default function page() {
autoplay: true, autoplay: true,
initialSubtitleId, initialSubtitleId,
initialAudioId, initialAudioId,
// Pass cache/buffer settings from user preferences
cacheConfig: {
enabled: settings.mpvCacheEnabled,
cacheSeconds: settings.mpvCacheSeconds,
maxBytes: settings.mpvDemuxerMaxBytes,
maxBackBytes: settings.mpvDemuxerMaxBackBytes,
},
// Pass VO driver setting (Android only)
voDriver: settings.mpvVoDriver,
}; };
// Add external subtitles only for online playback // Add external subtitles only for online playback
@@ -657,32 +589,17 @@ export default function page() {
source.externalSubtitles = externalSubs; source.externalSubtitles = externalSubs;
} }
// Add headers for online streaming (not for local file:// URLs) // Add auth headers only for online streaming (not for local file:// URLs)
if (!offline) { if (!offline && api?.accessToken) {
const headers: Record<string, string> = {}; source.headers = {
const isRemoteStream = Authorization: `MediaBrowser Token="${api.accessToken}"`,
mediaSource?.IsRemote && mediaSource?.Protocol === "Http"; };
// Add auth header only for Jellyfin API requests (not for external/remote streams)
if (api?.accessToken && !isRemoteStream) {
headers.Authorization = `MediaBrowser Token="${api.accessToken}"`;
}
// Add any required headers from the media source (e.g., for external/remote streams)
if (stream?.requiredHttpHeaders) {
Object.assign(headers, stream.requiredHttpHeaders);
}
if (Object.keys(headers).length > 0) {
source.headers = headers;
}
} }
return source; return source;
}, [ }, [
stream?.url, stream?.url,
stream?.mediaSource, stream?.mediaSource,
stream?.requiredHttpHeaders,
item?.UserData?.PlaybackPositionTicks, item?.UserData?.PlaybackPositionTicks,
playbackPositionFromUrl, playbackPositionFromUrl,
api?.basePath, api?.basePath,
@@ -690,11 +607,6 @@ export default function page() {
subtitleIndex, subtitleIndex,
audioIndex, audioIndex,
offline, offline,
settings.mpvCacheEnabled,
settings.mpvCacheSeconds,
settings.mpvDemuxerMaxBytes,
settings.mpvDemuxerMaxBackBytes,
settings.mpvVoDriver,
]); ]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
@@ -785,27 +697,23 @@ export default function page() {
setIsPlaying(true); setIsPlaying(true);
setIsBuffering(false); setIsBuffering(false);
setHasPlaybackStarted(true); setHasPlaybackStarted(true);
// Pause inactivity timer during playback (TV only)
pauseInactivityTimer();
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, currentPlayStateInfo() as PlaybackProgressInfo,
); );
} }
await activateKeepAwakeAsync(); if (!Platform.isTV) await activateKeepAwakeAsync();
return; return;
} }
if (isPaused) { if (isPaused) {
setIsPlaying(false); setIsPlaying(false);
// Resume inactivity timer when paused (TV only)
resumeInactivityTimer();
if (item?.Id) { if (item?.Id) {
playbackManager.reportPlaybackProgress( playbackManager.reportPlaybackProgress(
currentPlayStateInfo() as PlaybackProgressInfo, currentPlayStateInfo() as PlaybackProgressInfo,
); );
} }
await deactivateKeepAwake(); if (!Platform.isTV) await deactivateKeepAwake();
return; return;
} }
@@ -813,13 +721,7 @@ export default function page() {
setIsBuffering(isLoading); setIsBuffering(isLoading);
} }
}, },
[ [playbackManager, item?.Id, progress],
playbackManager,
item?.Id,
progress,
pauseInactivityTimer,
resumeInactivityTimer,
],
); );
/** PiP handler for MPV */ /** PiP handler for MPV */
@@ -861,55 +763,6 @@ export default function page() {
videoRef.current?.seekTo?.(position / 1000); videoRef.current?.seekTo?.(position / 1000);
}, []); }, []);
// TV audio track change handler
const handleAudioIndexChange = useCallback(
async (index: number) => {
setCurrentAudioIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvAudioId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined) {
await videoRef.current?.setAudioTrack?.(mpvTrackId);
}
},
[stream?.mediaSource],
);
// TV subtitle track change handler
const handleSubtitleIndexChange = useCallback(
async (index: number) => {
setCurrentSubtitleIndex(index);
// Check if we're transcoding
const isTranscoding = Boolean(stream?.mediaSource?.TranscodingUrl);
if (index === -1) {
// Disable subtitles
await videoRef.current?.disableSubtitles?.();
} else {
// Convert Jellyfin index to MPV track ID
const mpvTrackId = getMpvSubtitleId(
stream?.mediaSource,
index,
isTranscoding,
);
if (mpvTrackId !== undefined && mpvTrackId !== -1) {
await videoRef.current?.setSubtitleTrack?.(mpvTrackId);
}
}
},
[stream?.mediaSource],
);
// Technical info toggle handler // Technical info toggle handler
const handleToggleTechnicalInfo = useCallback(() => { const handleToggleTechnicalInfo = useCallback(() => {
setShowTechnicalInfo((prev) => !prev); setShowTechnicalInfo((prev) => !prev);
@@ -999,110 +852,6 @@ export default function page() {
} }
}, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]); }, [isZoomedToFill, stream?.mediaSource, screenWidth, screenHeight]);
// TV: Navigate to previous item
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(previousItem, settings, {
indexes: {
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
previousItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
previousItem,
settings,
subtitleIndex,
audioIndex,
stream?.mediaSource,
bitrateValue,
router,
]);
// TV: Add subtitle file to player (for client-side downloaded subtitles)
const addSubtitleFile = useCallback(async (path: string) => {
await videoRef.current?.addSubtitleFile?.(path, true);
}, []);
// TV: Refresh subtitle tracks after server-side subtitle download
// Re-fetches the media source to pick up newly downloaded subtitles
const handleRefreshSubtitleTracks = useCallback(async (): Promise<
MediaStream[]
> => {
if (!refetchStreamRef.current) return [];
const newStream = await refetchStreamRef.current();
// Check if component is still mounted before updating state
// This callback may be invoked from a modal after the player unmounts
if (!isMounted) return [];
if (newStream) {
setStream(newStream);
return (
newStream.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
) ?? []
);
}
return [];
}, [isMounted]);
// TV: Navigate to next item
const goToNextItem = useCallback(() => {
if (!nextItem || !settings || isPlaybackStopped) return;
const {
mediaSource: newMediaSource,
audioIndex: defaultAudioIndex,
subtitleIndex: defaultSubtitleIndex,
} = getDefaultPlaySettings(nextItem, settings, {
indexes: {
subtitleIndex: subtitleIndex,
audioIndex: audioIndex,
},
source: stream?.mediaSource ?? undefined,
});
const queryParams = new URLSearchParams({
itemId: nextItem.Id ?? "",
audioIndex: defaultAudioIndex?.toString() ?? "",
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
mediaSourceId: newMediaSource?.Id ?? "",
bitrateValue: bitrateValue?.toString() ?? "",
playbackPosition:
nextItem.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
router.replace(`player/direct-player?${queryParams}` as any);
}, [
nextItem,
settings,
subtitleIndex,
audioIndex,
stream?.mediaSource,
bitrateValue,
router,
isPlaybackStopped,
]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return; if (!isVideoLoaded || !videoRef.current) return;
@@ -1122,27 +871,14 @@ export default function page() {
if (settings.mpvSubtitleAlignY !== undefined) { if (settings.mpvSubtitleAlignY !== undefined) {
await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY); await videoRef.current?.setSubtitleAlignY?.(settings.mpvSubtitleAlignY);
} }
// Apply subtitle background (iOS only - doesn't work on tvOS due to composite OSD limitation) if (settings.mpvSubtitleFontSize !== undefined) {
// mpv uses #RRGGBBAA format (alpha last, same as CSS) await videoRef.current?.setSubtitleFontSize?.(
if (settings.mpvSubtitleBackgroundEnabled) { settings.mpvSubtitleFontSize,
const opacity = settings.mpvSubtitleBackgroundOpacity ?? 75;
const alphaHex = Math.round((opacity / 100) * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
// Enable background-box mode (required for sub-back-color to work)
await videoRef.current?.setSubtitleBorderStyle?.("background-box");
await videoRef.current?.setSubtitleBackgroundColor?.(
`#000000${alphaHex}`,
); );
// Force override ASS subtitle styles so background shows on styled subtitles }
await videoRef.current?.setSubtitleAssOverride?.("force"); // Apply subtitle size from general settings
} else { if (settings.subtitleSize) {
// Restore default outline-and-shadow style await videoRef.current?.setSubtitleFontSize?.(settings.subtitleSize);
await videoRef.current?.setSubtitleBorderStyle?.("outline-and-shadow");
await videoRef.current?.setSubtitleBackgroundColor?.("#00000000");
// Restore default ASS behavior (keep original styles)
await videoRef.current?.setSubtitleAssOverride?.("no");
} }
}; };
@@ -1163,28 +899,6 @@ export default function page() {
applyInitialPlaybackSpeed(); applyInitialPlaybackSpeed();
}, [isVideoLoaded, initialPlaybackSpeed]); }, [isVideoLoaded, initialPlaybackSpeed]);
// TV only: Pre-load locally downloaded subtitles when video loads
// This adds them to MPV's track list without auto-selecting them
useEffect(() => {
if (!Platform.isTV || !isVideoLoaded || !videoRef.current || !itemId)
return;
const preloadLocalSubtitles = async () => {
const localSubs = getSubtitlesForItem(itemId);
for (const sub of localSubs) {
// Verify file still exists (cache may have been cleared)
const subtitleFile = new File(sub.filePath);
if (!subtitleFile.exists) {
continue;
}
// Add subtitle file to MPV without selecting it (select: false)
await videoRef.current?.addSubtitleFile?.(sub.filePath, false);
}
};
preloadLocalSubtitles();
}, [isVideoLoaded, itemId]);
// Show error UI first, before checking loading/missingdata // Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) { if (itemStatus.isError || streamStatus.isError) {
return ( return (
@@ -1277,72 +991,37 @@ export default function page() {
</View> </View>
)} )}
</View> </View>
{isMounted === true && {isMounted === true && item && !isPipMode && (
item && <Controls
!isPipMode && mediaSource={stream?.mediaSource}
(Platform.isTV ? ( item={item}
<TVControls togglePlay={togglePlay}
mediaSource={stream?.mediaSource} isPlaying={isPlaying}
item={item} isSeeking={isSeeking}
togglePlay={togglePlay} progress={progress}
isPlaying={isPlaying} cacheProgress={cacheProgress}
isSeeking={isSeeking} isBuffering={isBuffering}
progress={progress} showControls={showControls}
cacheProgress={cacheProgress} setShowControls={setShowControls}
isBuffering={isBuffering} startPictureInPicture={startPictureInPicture}
showControls={showControls} play={play}
setShowControls={setShowControls} pause={pause}
play={play} seek={seek}
pause={pause} enableTrickplay={true}
seek={seek} aspectRatio={aspectRatio}
audioIndex={currentAudioIndex} isZoomedToFill={isZoomedToFill}
subtitleIndex={currentSubtitleIndex} onZoomToggle={handleZoomToggle}
onAudioIndexChange={handleAudioIndexChange} api={api}
onSubtitleIndexChange={handleSubtitleIndexChange} downloadedFiles={downloadedFiles}
previousItem={previousItem} playbackSpeed={currentPlaybackSpeed}
nextItem={nextItem} setPlaybackSpeed={handleSetPlaybackSpeed}
goToPreviousItem={goToPreviousItem} showTechnicalInfo={showTechnicalInfo}
goToNextItem={goToNextItem} onToggleTechnicalInfo={handleToggleTechnicalInfo}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks} getTechnicalInfo={getTechnicalInfo}
addSubtitleFile={addSubtitleFile} playMethod={playMethod}
showTechnicalInfo={showTechnicalInfo} transcodeReasons={transcodeReasons}
onToggleTechnicalInfo={handleToggleTechnicalInfo} />
getTechnicalInfo={getTechnicalInfo} )}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
downloadedFiles={downloadedFiles}
/>
) : (
<Controls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
startPictureInPicture={startPictureInPicture}
play={play}
pause={pause}
seek={seek}
enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle}
api={api}
downloadedFiles={downloadedFiles}
playbackSpeed={currentPlaybackSpeed}
setPlaybackSpeed={handleSetPlaybackSpeed}
showTechnicalInfo={showTechnicalInfo}
onToggleTechnicalInfo={handleToggleTechnicalInfo}
getTechnicalInfo={getTechnicalInfo}
playMethod={playMethod}
transcodeReasons={transcodeReasons}
/>
))}
</View> </View>
</VideoProvider> </VideoProvider>
</PlayerProvider> </PlayerProvider>

View File

@@ -1,189 +0,0 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVBackPress } from "@/hooks/useTVBackPress";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
import { scaleSize } from "@/utils/scaleSize";
import { store } from "@/utils/store";
export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
const typography = useScaledTVTypography();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
if (!modalState?.options) return 0;
const idx = modalState.options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [modalState?.options]);
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvOptionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (value: any) => {
modalState?.onSelect(value);
store.set(tvOptionModalAtom, null);
router.back();
};
const handleClose = useCallback(() => {
store.set(tvOptionModalAtom, null);
router.back();
}, [router]);
// Intercept back/menu press to close the modal instead of the player
useTVBackPress(() => {
handleClose();
return true;
}, [handleClose]);
// If no modal state, just go back (shouldn't happen in normal usage)
if (!modalState) {
return null;
}
const { title, options } = modalState;
const scaledCardWidth = scaleSize(160);
const scaledCardHeight = scaleSize(75);
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{title}
</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(option.value)}
width={scaledCardWidth}
height={scaledCardHeight}
/>
))}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: scaleSize(24),
borderTopRightRadius: scaleSize(24),
overflow: "hidden",
},
content: {
paddingTop: scaleSize(24),
paddingBottom: scaleSize(50),
overflow: "visible",
},
title: {
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: scaleSize(16),
paddingHorizontal: scaleSize(48),
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: scaleSize(48),
paddingVertical: scaleSize(20),
gap: scaleSize(12),
},
});

View File

@@ -1,496 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
export default function TVRequestModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: modalState?.id ? Number(modalState.id) : 0,
mediaType: modalState?.mediaType,
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvRequestModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
queryFn: async () =>
jellyseerrApi?.service(
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
),
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
});
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
modalState?.mediaType,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled:
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
});
const defaultProfile: QualityProfile | undefined = useMemo(
() =>
defaultServiceDetails?.profiles.find(
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
),
[defaultServiceDetails],
);
const defaultFolder: RootFolder | undefined = useMemo(
() =>
defaultServiceDetails?.rootFolders.find(
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
),
[defaultServiceDetails],
);
const defaultTags: Tag[] = useMemo(() => {
return (
defaultServiceDetails?.tags.filter((t) =>
defaultServiceDetails?.server.activeTags?.includes(t.id),
) ?? []
);
}, [defaultServiceDetails]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
// Option builders
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
() =>
defaultServiceDetails?.profiles.map((profile) => ({
label: profile.name,
value: profile.id,
selected:
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
})) || [],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
() =>
defaultServiceDetails?.rootFolders.map((folder) => ({
label: pathTitleExtractor(folder),
value: folder.path,
selected:
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
})) || [],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const userOptions: TVOptionItem<number>[] = useMemo(
() =>
users?.map((user) => ({
label: user.displayName,
value: user.id,
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
})) || [],
[users, jellyseerrUser, requestOverrides.userId],
);
const tagItems = useMemo(() => {
return (
defaultServiceDetails?.tags.map((tag) => ({
id: tag.id,
label: tag.label,
selected:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
})) ?? []
);
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
// Selected display values
const selectedProfileName = useMemo(() => {
const profile = defaultServiceDetails?.profiles.find(
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
);
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
}, [
defaultServiceDetails?.profiles,
requestOverrides.profileId,
defaultProfile,
t,
]);
const selectedFolderName = useMemo(() => {
const folder = defaultServiceDetails?.rootFolders.find(
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
);
return folder
? pathTitleExtractor(folder)
: defaultFolder
? pathTitleExtractor(defaultFolder)
: t("jellyseerr.select");
}, [
defaultServiceDetails?.rootFolders,
requestOverrides.rootFolder,
defaultFolder,
t,
]);
const selectedUserName = useMemo(() => {
const user = users?.find(
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
);
return (
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
);
}, [users, requestOverrides.userId, jellyseerrUser, t]);
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
(tagId: number) => {
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tagId);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tagId)
: [...currentTags, tagId],
};
});
},
[defaultTags],
);
const handleRequest = useCallback(() => {
if (!modalState) return;
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
...modalState.requestBody,
...requestOverrides,
};
const seasonTitle =
modalState.requestBody?.seasons?.length === 1
? t("jellyseerr.season_number", {
season_number: modalState.requestBody.seasons[0],
})
: modalState.requestBody?.seasons &&
modalState.requestBody.seasons.length > 1
? t("jellyseerr.season_all")
: undefined;
requestMedia(
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
body,
() => {
modalState.onRequested();
router.back();
},
);
}, [
modalState,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
defaultService,
defaultServiceDetails,
requestMedia,
router,
t,
]);
if (!modalState) {
return null;
}
const isDataLoaded = defaultService && defaultServiceDetails && users;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.advanced")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{isDataLoaded && isReady ? (
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
>
<View style={styles.optionsContainer}>
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
/>
{tagItems.length > 0 && (
<TVToggleOptionRow
label={t("jellyseerr.tags")}
items={tagItems}
onToggle={handleTagToggle}
/>
)}
</View>
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t("common.loading")}</Text>
</View>
)}
{isReady && (
<View style={styles.buttonContainer}>
<TVButton
onPress={handleRequest}
variant='secondary'
disabled={!isDataLoaded}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={[
styles.buttonText,
{ fontSize: typography.callout },
]}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
},
heading: {
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
scrollView: {
maxHeight: 320,
overflow: "visible",
},
optionsContainer: {
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
},
loadingContainer: {
height: 200,
justifyContent: "center",
alignItems: "center",
},
loadingText: {
color: "rgba(255,255,255,0.5)",
},
buttonContainer: {
marginTop: 24,
},
buttonText: {
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -1,446 +0,0 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { orderBy } from "lodash";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
interface TVSeasonToggleCardProps {
season: {
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
};
selected: boolean;
onToggle: () => void;
canRequest: boolean;
hasTVPreferredFocus?: boolean;
}
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
season,
selected,
onToggle,
canRequest,
hasTVPreferredFocus,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
// Get status icon and color based on MediaStatus
const getStatusIcon = (): {
icon: keyof typeof MaterialCommunityIcons.glyphMap;
color: string;
} | null => {
switch (season.status) {
case MediaStatus.PROCESSING:
return { icon: "clock", color: "#6366f1" };
case MediaStatus.AVAILABLE:
return { icon: "check", color: "#22c55e" };
case MediaStatus.PENDING:
return { icon: "bell", color: "#eab308" };
case MediaStatus.PARTIALLY_AVAILABLE:
return { icon: "minus", color: "#22c55e" };
case MediaStatus.BLACKLISTED:
return { icon: "eye-off", color: "#ef4444" };
default:
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
}
};
const statusInfo = getStatusIcon();
const isDisabled = !canRequest;
return (
<Pressable
onPress={canRequest ? onToggle : undefined}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isDisabled}
focusable={!isDisabled}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
styles.seasonCard,
{
backgroundColor: focused
? "#FFFFFF"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderWidth: focused ? 0 : 1,
borderColor: selected
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.1)",
opacity: isDisabled ? 0.5 : 1,
},
]}
>
{/* Checkmark for selected */}
<View style={styles.checkmarkContainer}>
{selected && (
<Ionicons
name='checkmark-circle'
size={24}
color={focused ? "#22c55e" : "#FFFFFF"}
/>
)}
</View>
{/* Season info */}
<View style={styles.seasonInfo}>
<Text
style={[
styles.seasonTitle,
{ color: focused ? "#000000" : "#FFFFFF" },
]}
numberOfLines={1}
>
{t("jellyseerr.season_number", {
season_number: season.seasonNumber,
})}
</Text>
<View style={styles.episodeRow}>
<Text
style={[
styles.episodeCount,
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
},
]}
>
{t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
})}
</Text>
{statusInfo && (
<View
style={[
styles.statusBadge,
{ backgroundColor: statusInfo.color },
]}
>
<MaterialCommunityIcons
name={statusInfo.icon}
size={14}
color='#FFFFFF'
/>
</View>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};
export default function TVSeasonSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeasonSelectModalAtom);
const { t } = useTranslation();
const { requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal();
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
new Set(),
);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Initialize selected seasons when modal state changes
useEffect(() => {
if (modalState?.seasons) {
const requestableSeasons = modalState.seasons
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
.map((s) => s.seasonNumber);
setSelectedSeasons(new Set(requestableSeasons));
}
}, [modalState?.seasons]);
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
return () => {
store.set(tvSeasonSelectModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Sort seasons by season number (ascending)
const sortedSeasons = useMemo(() => {
if (!modalState?.seasons) return [];
return orderBy(
modalState.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"asc",
);
}, [modalState?.seasons]);
// Find the index of the first requestable season for initial focus
const firstRequestableIndex = useMemo(() => {
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
}, [sortedSeasons]);
const handleToggleSeason = useCallback((seasonNumber: number) => {
setSelectedSeasons((prev) => {
const newSet = new Set(prev);
if (newSet.has(seasonNumber)) {
newSet.delete(seasonNumber);
} else {
newSet.add(seasonNumber);
}
return newSet;
});
}, []);
const handleRequestSelected = useCallback(() => {
if (!modalState || selectedSeasons.size === 0) return;
const seasonsArray = Array.from(selectedSeasons);
const body: MediaRequestBody = {
mediaId: modalState.mediaId,
mediaType: MediaType.TV,
tvdbId: modalState.tvdbId,
seasons: seasonsArray,
};
if (modalState.hasAdvancedRequestPermission) {
// Close this modal and open the advanced request modal
router.back();
showRequestModal({
requestBody: body,
title: modalState.title,
id: modalState.mediaId,
mediaType: MediaType.TV,
onRequested: modalState.onRequested,
});
return;
}
// Build the title based on selected seasons
const seasonTitle =
seasonsArray.length === 1
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
: seasonsArray.length === sortedSeasons.length
? t("jellyseerr.season_all")
: t("jellyseerr.n_selected", { count: seasonsArray.length });
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
modalState.onRequested();
router.back();
});
}, [
modalState,
selectedSeasons,
sortedSeasons.length,
requestMedia,
router,
t,
showRequestModal,
]);
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.select_seasons")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{/* Season cards horizontal scroll */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{sortedSeasons.map((season, index) => {
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
return (
<TVSeasonToggleCard
key={season.id}
season={season}
selected={selectedSeasons.has(season.seasonNumber)}
onToggle={() => handleToggleSeason(season.seasonNumber)}
canRequest={canRequestSeason}
hasTVPreferredFocus={index === firstRequestableIndex}
/>
);
})}
</ScrollView>
{/* Request button */}
<View style={styles.buttonContainer}>
<TVButton
onPress={handleRequestSelected}
variant='secondary'
disabled={selectedSeasons.size === 0}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={[styles.buttonText, { fontSize: typography.callout }]}
>
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
</Text>
</TVButton>
</View>
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
},
heading: {
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingVertical: 12,
paddingHorizontal: 4,
gap: 16,
},
seasonCard: {
width: 160,
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 8,
},
checkmarkContainer: {
height: 24,
marginBottom: 8,
},
seasonInfo: {
flex: 1,
},
seasonTitle: {
fontWeight: "600",
marginBottom: 4,
},
episodeRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
episodeCount: {
fontSize: 14,
},
statusBadge: {
width: 22,
height: 22,
borderRadius: 11,
justifyContent: "center",
alignItems: "center",
},
buttonContainer: {
marginTop: 24,
},
buttonText: {
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -1,190 +0,0 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton, TVOptionCard } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import { store } from "@/utils/store";
export default function TVSeriesSeasonModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
if (!modalState?.seasons) return 0;
const idx = modalState.seasons.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [modalState?.seasons]);
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvSeriesSeasonModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Focus on the selected card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (seasonIndex: number) => {
if (modalState?.onSeasonSelect) {
modalState.onSeasonSelect(seasonIndex);
}
router.back();
};
const handleCancel = () => {
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{t("item_card.select_season")}
</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{modalState.seasons.map((season, index) => (
<TVOptionCard
key={season.value}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={season.label}
selected={season.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => handleSelect(season.value)}
width={180}
height={85}
/>
))}
</ScrollView>
)}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton
onPress={handleCancel}
label={t("common.cancel")}
/>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,174 +0,0 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVUserCard } from "@/components/tv/TVUserCard";
import useRouter from "@/hooks/useAppRouter";
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
import type { SavedServerAccount } from "@/utils/secureCredentials";
import { store } from "@/utils/store";
export default function TVUserSwitchModalPage() {
const { t } = useTranslation();
const router = useRouter();
const modalState = useAtomValue(tvUserSwitchModalAtom);
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount and cleanup atom on unmount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
// Delay focus setup to allow layout
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
// Clear the atom on unmount to prevent stale callbacks from being retained
store.set(tvUserSwitchModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
// Request focus on the first card when ready
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
const handleSelect = (account: SavedServerAccount) => {
modalState?.onAccountSelect(account);
store.set(tvUserSwitchModalAtom, null);
router.back();
};
// If no modal state, just return null
if (!modalState) {
return null;
}
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>
{t("home.settings.switch_user.title")}
</Text>
<Text style={styles.subtitle}>{modalState.serverName}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{modalState.accounts.map((account, index) => {
const isCurrent = account.userId === modalState.currentUserId;
return (
<TVUserCard
key={account.userId}
ref={index === 0 ? firstCardRef : undefined}
username={account.username}
securityType={account.securityType}
hasTVPreferredFocus={index === 0}
isCurrent={isCurrent}
onPress={() => handleSelect(account)}
/>
);
})}
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 4,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
subtitle: {
fontSize: 14,
color: "rgba(255,255,255,0.4)",
marginBottom: 16,
paddingHorizontal: 48,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
},
});

View File

@@ -10,11 +10,9 @@ import * as BackgroundTask from "expo-background-task";
import * as Device from "expo-device"; import * as Device from "expo-device";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider";
import { InactivityProvider } from "@/providers/InactivityProvider";
import { IntroSheetProvider } from "@/providers/IntroSheetProvider"; import { IntroSheetProvider } from "@/providers/IntroSheetProvider";
import { import {
apiAtom, apiAtom,
@@ -56,31 +54,15 @@ import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider, useAtom } from "jotai";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Appearance, LogBox } from "react-native"; import { Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
// Suppress harmless tvOS warning from react-native-gesture-handler
if (Platform.isTV) {
LogBox.ignoreLogs(["HoverGestureHandler is not supported on tvOS"]);
}
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store as jotaiStore, store } from "@/utils/store"; import { store } from "@/utils/store";
import "react-native-reanimated"; import "react-native-reanimated";
import {
configureReanimatedLogger,
ReanimatedLogLevel,
} from "react-native-reanimated";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
// Disable strict mode warnings for reading shared values during render
configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
strict: false,
});
if (!Platform.isTV) { if (!Platform.isTV) {
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
@@ -196,7 +178,7 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider store={jotaiStore}> <JotaiProvider>
<ActionSheetProvider> <ActionSheetProvider>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Layout /> <Layout />
@@ -250,11 +232,6 @@ function Layout() {
const _segments = useSegments(); const _segments = useSegments();
const router = useRouter(); const router = useRouter();
// Enable TV menu key interception so React Native handles it instead of tvOS
useEffect(() => {
enableTVMenuKeyInterception();
}, []);
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en", settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
@@ -275,19 +252,22 @@ function Layout() {
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
userId: user.Id, userId: user.Id,
}) })
.then((_) => console.log("Posted expo push token"))
.catch((_) => .catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"), writeErrorLog("Failed to push expo push token to plugin"),
); );
} } else console.log("No token available");
}, [api, expoPushToken, user]); }, [api, expoPushToken, user]);
const registerNotifications = useCallback(async () => { const registerNotifications = useCallback(async () => {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
name: "default", name: "default",
}); });
// Create dedicated channel for download notifications // Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", { await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads", name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT, importance: Notifications.AndroidImportance.DEFAULT,
@@ -362,8 +342,8 @@ function Layout() {
url = `/(auth)/(tabs)/home/items/page?id=${itemId}`; url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season // summarized season notification for multiple episodes. Bring them to series season
} else { } else {
const seriesId = data?.seriesId; const seriesId = data.seriesId;
const seasonIndex = data?.seasonIndex; const seasonIndex = data.seasonIndex;
if (seasonIndex) { if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`; url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else { } else {
@@ -402,145 +382,79 @@ function Layout() {
}} }}
> >
<JellyfinProvider> <JellyfinProvider>
<InactivityProvider> <ServerUrlProvider>
<ServerUrlProvider> <NetworkStatusProvider>
<NetworkStatusProvider> <PlaySettingsProvider>
<PlaySettingsProvider> <LogProvider>
<LogProvider> <WebSocketProvider>
<WebSocketProvider> <DownloadProvider>
<DownloadProvider> <MusicPlayerProvider>
<MusicPlayerProvider> <GlobalModalProvider>
<GlobalModalProvider> <BottomSheetModalProvider>
<BottomSheetModalProvider> <IntroSheetProvider>
<IntroSheetProvider> <ThemeProvider value={DarkTheme}>
<ThemeProvider value={DarkTheme}> <SystemBars style='light' hidden={false} />
<SystemBars style='light' hidden={false} /> <Stack initialRouteName='(auth)/(tabs)'>
<Stack initialRouteName='(auth)/(tabs)'> <Stack.Screen
<Stack.Screen name='(auth)/(tabs)'
name='(auth)/(tabs)' options={{
options={{ headerShown: false,
headerShown: false, title: "",
title: "", header: () => null,
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
/> />
{!Platform.isTV && <GlobalModal />} <Stack.Screen
</ThemeProvider> name='(auth)/player'
</IntroSheetProvider> options={{
</BottomSheetModalProvider> headerShown: false,
</GlobalModalProvider> title: "",
</MusicPlayerProvider> header: () => null,
</DownloadProvider> }}
</WebSocketProvider> />
</LogProvider> <Stack.Screen
</PlaySettingsProvider> name='(auth)/now-playing'
</NetworkStatusProvider> options={{
</ServerUrlProvider> headerShown: false,
</InactivityProvider> presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<GlobalModal />
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</JellyfinProvider> </JellyfinProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>
); );

View File

@@ -1,13 +1,659 @@
import { Platform } from "react-native"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { Login } from "@/components/login/Login"; import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { TVLogin } from "@/components/login/TVLogin"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Keyboard,
KeyboardAvoidingView,
Platform,
Switch,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import type {
AccountSecurityType,
SavedServer,
} from "@/utils/secureCredentials";
const LoginPage: React.FC = () => { const CredentialsSchema = z.object({
if (Platform.isTV) { username: z.string().min(1, t("login.username_required")),
return <TVLogin />; });
const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const {
setServer,
login,
removeServer,
initiateQuickConnect,
loginWithSavedCredential,
loginWithPassword,
} = useJellyfin();
const {
apiUrl: _apiUrl,
username: _username,
password: _password,
} = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl || "");
const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: _username || "",
password: _password || "",
});
// Save account state
const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
const [pendingLogin, setPendingLogin] = useState<{
username: string;
password: string;
} | null>(null);
/**
* A way to auto login based on a link
*/
useEffect(() => {
(async () => {
if (_apiUrl) {
await setServer({
address: _apiUrl,
});
// Wait for server setup and state updates to complete
setTimeout(() => {
if (_username && _password) {
setCredentials({ username: _username, password: _password });
login(_username, _password);
}
}, 0);
}
})();
}, [_apiUrl, _username, _password]);
useEffect(() => {
navigation.setOptions({
headerTitle: serverName,
headerLeft: () =>
api?.basePath ? (
<TouchableOpacity
onPress={() => {
removeServer();
}}
className='flex flex-row items-center pr-2 pl-1'
>
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className=' ml-1 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
) : null,
});
}, [serverName, navigation, api?.basePath]);
const handleLogin = async () => {
Keyboard.dismiss();
const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return;
if (saveAccount) {
// Show save account modal to choose security type
setPendingLogin({
username: credentials.username,
password: credentials.password,
});
setShowSaveModal(true);
} else {
// Login without saving
await performLogin(credentials.username, credentials.password);
}
};
const performLogin = async (
username: string,
password: string,
options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true);
try {
await login(username, password, serverName, options);
} catch (error) {
if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message);
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
}
} finally {
setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
}
};
const handleQuickLoginWithSavedCredential = async (
serverUrl: string,
userId: string,
) => {
await loginWithSavedCredential(serverUrl, userId);
};
const handlePasswordLogin = async (
serverUrl: string,
username: string,
password: string,
) => {
await loginWithPassword(serverUrl, username, password);
};
const handleAddAccount = (server: SavedServer) => {
// Server is already selected, go to credential entry
setServer({ address: server.address });
if (server.name) {
setServerName(server.name);
}
};
/**
* Checks the availability and validity of a Jellyfin server URL.
*
* This function attempts to connect to a Jellyfin server using the provided URL.
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
*
* @param {string} url - The base URL of the Jellyfin server to check.
* @returns {Promise<string | undefined>} A Promise that resolves to:
* - The full URL (including protocol) if a valid Jellyfin server is found.
* - undefined if no valid server is found at the given URL.
*
* Side effects:
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
const checkUrl = useCallback(async (url: string) => {
setLoadingServerCheck(true);
const baseUrl = url.replace(/^https?:\/\//i, "");
const protocols = ["https", "http"];
try {
return checkHttp(baseUrl, protocols);
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}, []);
async function checkHttp(baseUrl: string, protocols: string[]) {
for (const protocol of protocols) {
try {
const response = await fetch(
`${protocol}://${baseUrl}/System/Info/Public`,
{
mode: "cors",
},
);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
const serverVersion = data.Version?.split(".");
if (serverVersion && +serverVersion[0] <= 10) {
if (+serverVersion[1] < 10) {
Alert.alert(
t("login.too_old_server_text"),
t("login.too_old_server_description"),
);
throw new Error("Server too old");
}
}
setServerName(data.ServerName || "");
return `${protocol}://${baseUrl}`;
}
} catch (e) {
if (e instanceof Error && e.message === "Server too old") {
throw e;
}
}
}
return undefined;
} }
/**
* Handles the connection attempt to a Jellyfin server.
*
* This function trims the input URL, checks its validity using the `checkUrl` function,
* and sets the server address if a valid connection is established.
*
* @param {string} url - The URL of the Jellyfin server to connect to.
*
* @returns {Promise<void>}
*
* Side effects:
* - Calls `checkUrl` to validate the server URL.
* - Shows an alert if the connection fails.
* - Sets the server address using `setServer` if the connection is successful.
*
*/
const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, "");
try {
const result = await checkUrl(url);
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server"),
);
return;
}
await setServer({ address: result });
} catch {}
}, []);
return <Login />; const handleQuickConnect = async () => {
try {
const code = await initiateQuickConnect();
if (code) {
Alert.alert(
t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }),
[
{
text: t("login.got_it"),
},
],
);
}
} catch (_error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
}
};
return Platform.isTV ? (
// TV layout
<SafeAreaView className='flex-1 bg-black'>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
{api?.basePath ? (
// ------------ Username/Password view ------------
<View className='flex-1 items-center justify-center'>
{/* Safe centered column with max width so TV doesnt stretch too far */}
<View className='w-[92%] max-w-[900px] px-2 -mt-12'>
<Text className='text-3xl font-bold text-white mb-1'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-500'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400 mb-6'>
{api.basePath}
</Text>
{/* Username */}
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text: string) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({ ...prev, username: newValue }));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
{/* Password */}
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text: string) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({ ...prev, password: newValue }));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
extraClassName='mb-4'
autoFocus={false}
blurOnSubmit={true}
/>
<View className='mt-4'>
<Button
onPress={handleLogin}
disabled={!credentials.username.trim()}
>
{t("login.login_button")}
</Button>
</View>
<View className='mt-3'>
<Button
onPress={handleQuickConnect}
className='bg-neutral-800 border border-neutral-700'
>
{t("login.quick_connect")}
</Button>
</View>
</View>
</View>
) : (
// ------------ Server connect view ------------
<View className='flex-1 items-center justify-center'>
<View className='w-[92%] max-w-[900px] -mt-2'>
<View className='items-center mb-1'>
<Image
source={require("@/assets/images/icon-ios-plain.png")}
style={{ width: 110, height: 110 }}
contentFit='contain'
/>
</View>
<Text className='text-white text-4xl font-bold text-center'>
Streamyfin
</Text>
<Text className='text-neutral-400 text-base text-left mt-2 mb-1'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Full-width Input with clear focus ring */}
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
autoFocus={false}
blurOnSubmit={true}
/>
{/* Full-width primary button */}
<View className='mt-4'>
<Button
onPress={async () => {
await handleConnect(serverURL);
}}
>
{t("server.connect_button")}
</Button>
</View>
{/* Lists stay full width but inside max width container */}
<View className='mt-2'>
<JellyfinServerDiscovery
onServerSelect={async (server: any) => {
setServerURL(server.address);
if (server.serverName) setServerName(server.serverName);
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
) : (
// Mobile layout
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={{ flex: 1 }}
>
{api?.basePath ? (
<View className='flex flex-col flex-1 justify-center'>
<View className='px-4 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
{serverName ? (
<>
{`${t("login.login_to_title")} `}
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</Text>
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
<Input
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, username: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.username) {
setCredentials((prev) => ({
...prev,
username: newValue,
}));
}
}}
value={credentials.username}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
autoCorrect={false}
textContentType='username'
clearButtonMode='while-editing'
maxLength={500}
/>
<Input
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials((prev) => ({ ...prev, password: text }))
}
onEndEditing={(e) => {
const newValue = e.nativeEvent.text;
if (newValue && newValue !== credentials.password) {
setCredentials((prev) => ({
...prev,
password: newValue,
}));
}
}}
value={credentials.password}
secureTextEntry
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<TouchableOpacity
onPress={() => setSaveAccount(!saveAccount)}
className='flex flex-row items-center py-2'
activeOpacity={0.7}
>
<Switch
value={saveAccount}
onValueChange={setSaveAccount}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
<Text className='ml-3 text-neutral-300'>
{t("save_account.save_for_later")}
</Text>
</TouchableOpacity>
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
disabled={!credentials.username.trim()}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</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'>
<Image
style={{
width: 100,
height: 100,
marginLeft: -23,
marginBottom: -20,
}}
source={require("@/assets/images/icon-ios-plain.png")}
/>
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => {
await handleConnect(serverURL);
}}
className='w-full grow'
>
{t("server.connect_button")}
</Button>
<JellyfinServerDiscovery
onServerSelect={async (server) => {
setServerURL(server.address);
if (server.serverName) {
setServerName(server.serverName);
}
await handleConnect(server.address);
}}
/>
<PreviousServersList
onServerSelect={async (s) => {
await handleConnect(s.address);
}}
onQuickLogin={handleQuickLoginWithSavedCredential}
onPasswordLogin={handlePasswordLogin}
onAddAccount={handleAddAccount}
/>
</View>
</View>
)}
</KeyboardAvoidingView>
{/* Save Account Modal */}
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView>
);
}; };
export default LoginPage; export default Login;

View File

@@ -1,33 +0,0 @@
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
export default function TopShelfItemRedirect() {
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const { id, type } = useLocalSearchParams<{
id?: string;
type?: string;
}>();
useEffect(() => {
if (!rootNavigationState?.key) {
return;
}
if (!id) {
router.replace("/(auth)/(tabs)/(home)");
return;
}
if (type === "Series") {
router.replace(`/(auth)/(tabs)/(home)/series/${id}`);
return;
}
router.replace(`/(auth)/(tabs)/(home)/items/page?id=${id}`);
}, [id, rootNavigationState?.key, router, type]);
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

View File

@@ -1,32 +0,0 @@
import { useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useEffect } from "react";
import { View } from "react-native";
import useRouter from "@/hooks/useAppRouter";
export default function TopShelfPlayRedirect() {
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const { id } = useLocalSearchParams<{
id?: string;
}>();
useEffect(() => {
if (!rootNavigationState?.key) {
return;
}
if (!id) {
router.replace("/(auth)/(tabs)/(home)");
return;
}
const queryParams = new URLSearchParams({
itemId: id,
offline: "false",
});
router.replace(`/player/direct-player?${queryParams.toString()}`);
}, [id, rootNavigationState?.key, router]);
return <View style={{ flex: 1, backgroundColor: "#000" }} />;
}

View File

@@ -1,251 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
import { store } from "@/utils/store";
// Action card component
const TVAccountActionCard: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const typography = useScaledTVTypography();
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
height: 60,
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 24,
gap: 12,
}}
>
<Ionicons
name={icon}
size={22}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVAccountActionModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountActionModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountActionModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const handleLogin = () => {
modalState?.onLogin();
router.back();
};
const handleDelete = () => {
modalState?.onDelete();
router.back();
};
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Account username as title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{modalState.account.username}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* Horizontal options */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVAccountActionCard
label={t("common.login")}
icon='log-in-outline'
hasTVPreferredFocus
onPress={handleLogin}
/>
<TVAccountActionCard
label={t("common.delete")}
icon='trash-outline'
variant='destructive'
onPress={handleDelete}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

View File

@@ -1,256 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVUserCard } from "@/components/tv/TVUserCard";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
import { store } from "@/utils/store";
// Action button for bottom sheet
const TVAccountSelectAction: React.FC<{
label: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: "default" | "destructive";
onPress: () => void;
}> = ({ label, icon, variant = "default", onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const typography = useScaledTVTypography();
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isDestructive = variant === "destructive";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
backgroundColor: focused
? isDestructive
? "#ef4444"
: "#fff"
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 14,
minHeight: 72,
gap: 14,
}}
>
<Ionicons
name={icon}
size={22}
color={
focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff"
}
/>
<Text
style={{
fontSize: typography.callout,
color: focused
? isDestructive
? "#fff"
: "#000"
: isDestructive
? "#ef4444"
: "#fff",
fontWeight: "600",
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
export default function TVAccountSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvAccountSelectModalAtom);
const { t } = useTranslation();
const [isReady, setIsReady] = useState(false);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvAccountSelectModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
if (!modalState) {
return null;
}
return (
<Animated.View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 4,
paddingHorizontal: 48,
}}
>
{t("server.select_account")}
</Text>
{/* Server name as subtitle */}
<Text
style={{
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
}}
>
{modalState.server.name || modalState.server.address}
</Text>
{/* All options in single horizontal row */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 20,
gap: 16,
}}
>
{modalState.server.accounts?.map((account, index) => (
<TVUserCard
key={account.userId}
username={account.username}
securityType={account.securityType}
onPress={() => {
modalState.onAccountAction(account);
}}
hasTVPreferredFocus={index === 0}
/>
))}
<TVAccountSelectAction
label={t("server.add_account")}
icon='person-add-outline'
onPress={() => {
modalState.onAddAccount();
router.back();
}}
/>
<TVAccountSelectAction
label={t("server.remove_server")}
icon='trash-outline'
variant='destructive'
onPress={() => {
modalState.onDeleteServer();
router.back();
}}
/>
</ScrollView>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

View File

@@ -11,7 +11,7 @@ Number.prototype.bytesToReadable = function (decimals = 2) {
const bytes = this.valueOf(); const bytes = this.valueOf();
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1000; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**/*", "**/*",
@@ -8,8 +8,6 @@
"!android", "!android",
"!Streamyfin.app", "!Streamyfin.app",
"!utils/jellyseerr", "!utils/jellyseerr",
"!expo-env.d.ts",
"!modules/**/android/build",
"!.expo", "!.expo",
"!docs/jellyfin-openapi-stable.json" "!docs/jellyfin-openapi-stable.json"
] ]

View File

@@ -1,29 +0,0 @@
diff --git a/lib/generators/modules/GenerateModuleObjCpp/index.js b/lib/generators/modules/GenerateModuleObjCpp/index.js
index 927711514d2deaa3c795fb98e676e0a1f596eddc..0364d66204a76fccd3e06a0dc72bf801aa04a50d 100644
--- a/lib/generators/modules/GenerateModuleObjCpp/index.js
+++ b/lib/generators/modules/GenerateModuleObjCpp/index.js
@@ -67,9 +67,12 @@ const HeaderFileTemplate = ({
* must have a single output. More files => more genrule()s => slower builds.
*/
-#ifndef __cplusplus
-#error This file must be compiled as Obj-C++. If you are importing it, you must change your file extension to .mm.
-#endif
+// Patched: guard the Obj-C++ body with __cplusplus instead of hard-#error-ing.
+// With use_frameworks! :static + New Arch, plain Obj-C .m TUs can trigger a
+// Clang module build (via Swift-interop -Swift.h umbrellas) that pulls this
+// header in Obj-C mode. Skipping the body (instead of erroring) lets the module
+// build; .mm consumers still get the full Obj-C++ contents unchanged.
+#if defined(__cplusplus)
// Avoid multiple includes of ${headerFileNameWithNoExt} symbols
#ifndef ${headerFileNameWithNoExt}_H
@@ -93,7 +96,7 @@ const HeaderFileTemplate = ({
structInlineMethods +
(assumeNonnull ? '\nNS_ASSUME_NONNULL_END\n' : '\n') +
`#endif // ${headerFileNameWithNoExt}_H` +
- '\n'
+ '\n#endif // defined(__cplusplus)\n'
);
};
const SourceFileTemplate = ({headerFileName, moduleImplementations}) => `/**

View File

@@ -1,84 +0,0 @@
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/ios/BottomAccessoryProvider.swift
+++ b/ios/BottomAccessoryProvider.swift
@@ -8,7 +8,7 @@ import SwiftUI
self.delegate = delegate
}
- #if !os(macOS)
+ #if !os(macOS) && !os(tvOS)
@available(iOS 26.0, *)
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
var placementValue = "none"
diff --git a/ios/TabView/NewTabView.swift b/ios/TabView/NewTabView.swift
index 22c52cdf25ad0f7398d89197cb431ca8dc8e0f99..81411376e68803de8bd83515d42565cfa95daf2b 100644
--- a/ios/TabView/NewTabView.swift
+++ b/ios/TabView/NewTabView.swift
@@ -78,11 +78,11 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
func body(content: Content) -> some View {
- #if os(macOS)
- // tabViewBottomAccessory is not available on macOS
+ #if os(macOS) || os(tvOS)
+ // tabViewBottomAccessory is not available on macOS or tvOS
content
#else
- if #available(iOS 26.0, tvOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil {
+ if #available(iOS 26.0, visionOS 3.0, *), bottomAccessoryView != nil {
content
.tabViewBottomAccessory {
renderBottomAccessoryView()
@@ -95,7 +95,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
@ViewBuilder
private func renderBottomAccessoryView() -> some View {
- #if !os(macOS)
+ #if !os(macOS) && !os(tvOS)
if let bottomAccessoryView {
if #available(iOS 26.0, *) {
BottomAccessoryRepresentableView(view: bottomAccessoryView)
@@ -105,7 +105,7 @@ struct ConditionalBottomAccessoryModifier: ViewModifier {
}
}
-#if !os(macOS)
+#if !os(macOS) && !os(tvOS)
@available(iOS 26.0, *)
struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
@Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement
@@ -135,3 +135,4 @@ struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
}
}
#endif
+
diff --git a/ios/TabViewImpl.swift b/ios/TabViewImpl.swift
index 72938be90540ea3a483d7db9a80fb74c04d31272..277278ffdd9268a96cb09869eb1d0c0d5e6ad300 100644
--- a/ios/TabViewImpl.swift
+++ b/ios/TabViewImpl.swift
@@ -281,7 +281,7 @@ extension View {
@ViewBuilder
func tabBarMinimizeBehavior(_ behavior: MinimizeBehavior?) -> some View {
- #if compiler(>=6.2)
+ #if compiler(>=6.2) && !os(tvOS)
if #available(iOS 26.0, macOS 26.0, *) {
if let behavior {
self.tabBarMinimizeBehavior(behavior.convert())
diff --git a/ios/TabViewProps.swift b/ios/TabViewProps.swift
index 9cfb29a983b34d3f84fc7a678d19ef4ff30e0325..6a5854483e66200b71722bbac12e100742222bd3 100644
--- a/ios/TabViewProps.swift
+++ b/ios/TabViewProps.swift
@@ -6,7 +6,7 @@ internal enum MinimizeBehavior: String {
case onScrollUp
case onScrollDown
-#if compiler(>=6.2)
+#if compiler(>=6.2) && !os(tvOS)
@available(iOS 26.0, macOS 26.0, *)
func convert() -> TabBarMinimizeBehavior {
#if os(macOS)

View File

@@ -1,191 +0,0 @@
diff --git a/node_modules/react-native-screens/.bun-tag-10a3b0add1bd4de6 b/.bun-tag-10a3b0add1bd4de6
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/react-native-screens/.bun-tag-6a8504b742d5cfff b/.bun-tag-6a8504b742d5cfff
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/react-native-screens/.bun-tag-d28396854bc27a3d b/.bun-tag-d28396854bc27a3d
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ios/RNSScreenStack.mm b/ios/RNSScreenStack.mm
index 51f021831aed26a4eed3c85014020423b7b3108b..2f621547932806b94ab1e75ecc73772facd209d0 100644
--- a/ios/RNSScreenStack.mm
+++ b/ios/RNSScreenStack.mm
@@ -34,6 +34,11 @@
#import "integrations/RNSDismissibleModalProtocol.h"
#import "utils/UINavigationBar+RNSUtility.h"
+#if TARGET_OS_TV
+#import <React/RCTTVNavigationEventNotification.h>
+#import <React/RCTTVRemoteHandler.h>
+#endif // TARGET_OS_TV
+
#ifdef RNS_GAMMA_ENABLED
#import "RNSFrameCorrectionProvider.h"
#import "Swift-Bridging.h"
@@ -43,6 +48,12 @@
namespace react = facebook::react;
#endif // RCT_NEW_ARCH_ENABLED
+#if TARGET_OS_TV
+@interface RNSNavigationController ()
+@property (nonatomic, strong) UITapGestureRecognizer *rnscreens_menuGestureRecognizer;
+@end
+#endif // TARGET_OS_TV
+
@interface RNSScreenStackView () <
UINavigationControllerDelegate,
UIAdaptivePresentationControllerDelegate,
@@ -62,6 +73,57 @@ namespace react = facebook::react;
@implementation RNSNavigationController
+#if TARGET_OS_TV
+- (void)viewDidLoad
+{
+ [super viewDidLoad];
+
+ self.rnscreens_menuGestureRecognizer =
+ [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rnscreens_menuPressed:)];
+ self.rnscreens_menuGestureRecognizer.allowedPressTypes = @[ @(UIPressTypeMenu) ];
+
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(rnscreens_enableMenuGesture)
+ name:RCTTVEnableMenuKeyNotification
+ object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(rnscreens_disableMenuGesture)
+ name:RCTTVDisableMenuKeyNotification
+ object:nil];
+
+ if ([RCTTVRemoteHandler useMenuKey]) {
+ [self rnscreens_enableMenuGesture];
+ }
+}
+
+- (void)dealloc
+{
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+}
+
+- (void)rnscreens_enableMenuGesture
+{
+ if (![self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
+ [self.view addGestureRecognizer:self.rnscreens_menuGestureRecognizer];
+ }
+}
+
+- (void)rnscreens_disableMenuGesture
+{
+ if ([self.view.gestureRecognizers containsObject:self.rnscreens_menuGestureRecognizer]) {
+ [self.view removeGestureRecognizer:self.rnscreens_menuGestureRecognizer];
+ }
+}
+
+- (void)rnscreens_menuPressed:(UIGestureRecognizer *)recognizer
+{
+ [[NSNotificationCenter defaultCenter] postNavigationPressEventWithType:RCTTVRemoteEventMenu
+ keyAction:recognizer.eventKeyAction
+ tag:nil
+ target:nil];
+}
+#endif // TARGET_OS_TV
+
#if !TARGET_OS_TV
- (UIViewController *)childViewControllerForStatusBarStyle
{
diff --git a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
index 95c76ccf3528d3a8828e90b272a1d79b0828a139..f29d4df21440d23523ae7a2f6fe71c32154e3928 100644
--- a/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
+++ b/ios/gamma/split-view/RNSSplitViewAppearanceApplicator.swift
@@ -79,11 +79,13 @@ class RNSSplitViewAppearanceApplicator {
maxWidth: splitView.maximumSupplementaryColumnWidth)
#if compiler(>=6.2)
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
validateColumnConstraints(
minWidth: splitView.minimumInspectorColumnWidth,
maxWidth: splitView.maximumInspectorColumnWidth)
}
+ #endif
#endif
// Step 2.2 - applying updates to columns
@@ -126,6 +128,7 @@ class RNSSplitViewAppearanceApplicator {
}
#if compiler(>=6.2)
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
if splitView.minimumSecondaryColumnWidth >= 0 {
splitViewController.minimumSecondaryColumnWidth = splitView.minimumSecondaryColumnWidth
@@ -159,6 +162,7 @@ class RNSSplitViewAppearanceApplicator {
splitView.preferredInspectorColumnWidthOrFraction
}
}
+ #endif
#endif
// Step 2.3 - manipulating with inspector column
diff --git a/ios/gamma/split-view/RNSSplitViewHostController.swift b/ios/gamma/split-view/RNSSplitViewHostController.swift
index 0421e3ea92fc7bcdf57417b5ee3a62348fce34f5..cd878ab638d3c78a661e2df4c4c1b21011dfcf48 100644
--- a/ios/gamma/split-view/RNSSplitViewHostController.swift
+++ b/ios/gamma/split-view/RNSSplitViewHostController.swift
@@ -386,7 +386,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
/// @param inspectors An array of inspector-type RNSSplitViewScreenComponentView subviews.
///
func maybeSetupInspector(_ inspectors: [RNSSplitViewScreenComponentView]) {
-
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
let inspector = inspectors.first
if inspector != nil {
@@ -395,6 +395,7 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
setViewController(inspectorViewController, for: .inspector)
}
}
+ #endif
}
///
@@ -404,9 +405,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
/// Uses the UISplitViewController's new API introduced in iOS 26 to show the inspector column.
///
func maybeShowInspector() {
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
show(.inspector)
}
+ #endif
}
///
@@ -416,9 +419,11 @@ extension RNSSplitViewHostController: RNSSplitViewNavigationControllerViewFrameO
/// Uses the UISplitViewController's new API introduced in iOS 26 to hide the inspector column.
///
func maybeHideInspector() {
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
hide(.inspector)
}
+ #endif
}
}
#endif
@@ -444,6 +449,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
public func splitViewController(
_ svc: UISplitViewController, didHide column: UISplitViewController.Column
) {
+ #if !os(tvOS)
if #available(iOS 26.0, *) {
// TODO: we may consider removing this logic, because it could be handled by onViewDidDisappear on the column level
// On the other hand, maybe dedicated event related to the inspector would be a better approach.
@@ -461,6 +467,7 @@ extension RNSSplitViewHostController: UISplitViewControllerDelegate {
}
}
}
+ #endif
}
#endif

View File

@@ -1,17 +0,0 @@
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/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"]
s.author = { package_json["author"] => package_json["author"] }
- s.platform = :ios, "7.0"
+ s.ios.deployment_target = "7.0"
+ s.tvos.deployment_target = "15.1"
s.source = { :git => package_json["repository"]["url"], :tag => "v#{s.version}" }
s.source_files = 'ios/**/*.{h,m}'
s.dependency 'React-Core'

1841
bun.lock

File diff suppressed because it is too large Load Diff

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