Compare commits

..

92 Commits

Author SHA1 Message Date
Fredrik Burmester
4b7007386f fix(tv): font size 2026-01-20 22:15:00 +01:00
Fredrik Burmester
d2790f4997 fix(tv): seek 2026-01-20 22:15:00 +01:00
Fredrik Burmester
096670a0c3 fix(tv): better seek 2026-01-20 22:15:00 +01:00
Fredrik Burmester
aa6b441dd1 feat(tv): minimal seekbar 2026-01-20 22:15:00 +01:00
Fredrik Burmester
d8512897ad feat: seekbar left/right actions 2026-01-20 22:15:00 +01:00
Fredrik Burmester
11b6f16cd3 fix: scale button 2026-01-20 22:15:00 +01:00
Fredrik Burmester
506d8b14dc fix(tv): wrap actor page in scrollview to fix focus navigation between sections 2026-01-20 22:15:00 +01:00
Fredrik Burmester
a8acdf4299 feat(tv): hide music and playlists libraries on tv 2026-01-20 22:15:00 +01:00
Fredrik Burmester
2a9f4c2885 fix: design 2026-01-20 22:15:00 +01:00
Fredrik Burmester
0353a718f3 feat(tv): seerr 2026-01-20 22:15:00 +01:00
Fredrik Burmester
e3b4952c60 fix(tv): resolve jellyseerr detail page focus navigation loop 2026-01-20 22:15:00 +01:00
Fredrik Burmester
5f44540b6f fix(tv): design 2026-01-19 20:01:00 +01:00
Fredrik Burmester
4705c9f4f9 feat(tv): add favorite button to item detail page 2026-01-19 20:01:00 +01:00
Fredrik Burmester
2b36d4bc76 fix(tv): font sizes 2026-01-19 20:01:00 +01:00
Fredrik Burmester
f4445c4152 chore(i18n): add movies and shows translation keys for tv actor page 2026-01-19 20:01:00 +01:00
Fredrik Burmester
16a236393d refactor(tv): migrate series season selector to navigation-based modal pattern 2026-01-19 20:01:00 +01:00
Fredrik Burmester
eeb4ef3008 feat(tv): split actor filmography into movies and series sections 2026-01-19 20:01:00 +01:00
Fredrik Burmester
a173db9180 wip 2026-01-19 08:21:55 +01:00
Fredrik Burmester
a8c07a31d3 fix(tv): remove extra left margin from see all card in collection lists 2026-01-18 22:20:43 +01:00
Fredrik Burmester
493df28b8d fix(player): resolve tvOS freeze on player exit by reordering mpv options 2026-01-18 22:11:35 +01:00
Fredrik Burmester
749473c1e8 feat(tv): add subtitle settings to subtitle modal
Add a new "Settings" tab to the TV subtitle modal with controls for:
- Subtitle Scale (0.5x to 2.0x)
- Vertical Margin (-100 to +100)
- Horizontal Alignment (left, center, right)
- Vertical Alignment (top, center, bottom)

All settings use mpvSubtitle* settings for direct MPV control.
Includes English translations for all new settings.
2026-01-18 20:41:28 +01:00
Fredrik Burmester
f8d1fad6d5 refactor 2026-01-18 20:06:09 +01:00
Fredrik Burmester
81af2afef8 feat(tv): add see all card to recently added sections with focus handling improvements 2026-01-18 19:58:10 +01:00
Fredrik Burmester
9ef79ef364 Merge branch 'develop' into feat/tv-interface 2026-01-18 19:38:27 +01:00
lance chant
c7077bbcfe fix: subrip mpv (#1375)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-18 19:35:14 +01:00
Alex
c0f25a2b8b Add caching progress in seek slider bar (#1376) 2026-01-18 19:34:39 +01:00
Fredrik Burmester
83babc2687 refactor 2026-01-18 19:33:42 +01:00
Fredrik Burmester
f9a3a1f9f6 feat(tv): add live subtitle track refresh after opensubs download 2026-01-18 17:44:13 +01:00
Fredrik Burmester
0f076d197f style(tv): update stepper buttons to square with rounded corners 2026-01-18 17:22:44 +01:00
Fredrik Burmester
d28b5411d5 style(tv): add apple tv-style badges to search page 2026-01-18 17:22:41 +01:00
Fredrik Burmester
1da49d29d7 style(tv): update settings to use apple tv-style white and green accents 2026-01-18 16:25:00 +01:00
Fredrik Burmester
7af4b913d7 fix(tv): add keyboard focus to text inputs and polish poster styling 2026-01-18 16:13:53 +01:00
Fredrik Burmester
a667723d93 fix(tv): improve subtitle modal loading state and card consistency 2026-01-18 15:25:40 +01:00
Fredrik Burmester
94bfa26041 feat(tv): add opensubtitles api key setting to tv interface 2026-01-18 15:25:31 +01:00
Fredrik Burmester
d545ca3584 fix(tv): modals 2026-01-18 15:22:44 +01:00
Fredrik Burmester
773701d0c1 fix: translations 2026-01-18 14:52:45 +01:00
Fredrik Burmester
a3f7d0c275 feat(tv): add metadata refresh button to item details page 2026-01-18 14:52:06 +01:00
Fredrik Burmester
5b7ded08cc refactor(tv): extract shared components to reduce code duplication 2026-01-18 14:45:18 +01:00
Fredrik Burmester
60dd00ad7e fix: close button modals 2026-01-18 14:14:23 +01:00
Fredrik Burmester
ec653cae15 docs: hdr 2026-01-18 13:53:19 +01:00
Fredrik Burmester
18bc45ea0a feat: open subtitles 2026-01-18 13:20:17 +01:00
Fredrik Burmester
ebb33854d7 wip 2026-01-18 12:37:12 +01:00
Fredrik Burmester
9efa2bbaa2 wip: hdr 2026-01-18 11:58:32 +01:00
Fredrik Burmester
c515d037cf refactor(tv): unify subtitle track selector and search into tabbed sheet 2026-01-18 11:13:57 +01:00
Fredrik Burmester
ee3a288fa0 wip 2026-01-18 10:38:06 +01:00
Fredrik Burmester
c0171aa656 feat(tv): add actor detail page with dynamic backdrop crossfade 2026-01-17 09:32:47 +01:00
Fredrik Burmester
41d3e61261 feat(tv): add bidirectional focus navigation between options and cast list 2026-01-17 09:10:27 +01:00
Fredrik Burmester
8f74c3edc7 feat(tv): actors and stuff 2026-01-16 23:36:15 +01:00
Fredrik Burmester
56ffec3173 fix(player): add null guards for item in play settings 2026-01-16 21:26:56 +01:00
Fredrik Burmester
9509a427c8 wip 2026-01-16 21:22:23 +01:00
Fredrik Burmester
cfcfb486bf wip 2026-01-16 21:21:58 +01:00
Fredrik Burmester
407ea69425 fix(tv): add opening animations to bottom sheet option selectors 2026-01-16 21:03:06 +01:00
Fredrik Burmester
e1e91ea1a6 fix: sheet 2026-01-16 21:00:46 +01:00
Fredrik Burmester
e7ea8a2c3b fix: remove back button 2026-01-16 19:51:27 +01:00
Fredrik Burmester
9f1791ce93 wip 2026-01-16 19:05:25 +01:00
Fredrik Burmester
38cb7068ef style(search): remove redundant search label on TV search page 2026-01-16 19:04:13 +01:00
Fredrik Burmester
cc154f0c16 fix(tv): fix subtitle sheet issues on TV
- Hide subtitle button when no subtitle tracks available
- Add back/menu button handling to close option sheets
2026-01-16 18:57:38 +01:00
Fredrik Burmester
866aa44277 wip: controls next up 2026-01-16 17:16:08 +01:00
Fredrik Burmester
ff3f88c53b wip 2026-01-16 15:59:26 +01:00
Fredrik Burmester
3fd76b1356 wip 2026-01-16 15:29:12 +01:00
Fredrik Burmester
a86df6c46b wip 2026-01-16 14:48:08 +01:00
Fredrik Burmester
bdd284b9a6 fix(i18n): add missing common.login translation key 2026-01-16 13:22:26 +01:00
Fredrik Burmester
fff7d4459f feat(tv): improve settings focus management with disabled props pattern 2026-01-16 13:17:12 +01:00
Fredrik Burmester
b85549016d style(tv): increase top padding on item content page 2026-01-16 13:15:53 +01:00
Fredrik Burmester
6c35608404 fix(tv): regenerate icons with proper aspect ratios 2026-01-16 13:09:30 +01:00
Fredrik Burmester
74e3465a84 feat(tv): add tv card design to watchlist detail page 2026-01-16 13:06:12 +01:00
Fredrik Burmester
be32d933bb feat(tv): add option selector for playback settings 2026-01-16 13:00:26 +01:00
Fredrik Burmester
db89295d9b feat(player): add Apple TV remote play/pause and AirPlay support
- Add playPause event handling in useRemoteControl hook
- Configure AVAudioSession for tvOS with longFormAudio policy
- Add AVInitialRouteSharingPolicy to enable AirPlay suggestions
2026-01-16 12:42:13 +01:00
Fredrik Burmester
8d90fe3a8b fix(tv): implement remote control seeking on tv interface 2026-01-16 12:40:37 +01:00
Fredrik Burmester
4880392197 fix(tv): login form 2026-01-16 12:19:47 +01:00
Fredrik Burmester
e10a99cc48 wip: build for tv 2026-01-16 10:47:48 +01:00
Fredrik Burmester
55b897883b wip 2026-01-16 10:06:41 +01:00
Fredrik Burmester
fe26a74451 wip: home page 2026-01-16 09:11:27 +01:00
Fredrik Burmester
4cdbab7d19 wip 2026-01-16 08:57:22 +01:00
Fredrik Burmester
3e695def23 wip 2026-01-16 08:57:19 +01:00
Fredrik Burmester
15e4c18d54 fix(tvos): settings 2026-01-16 08:42:53 +01:00
Fredrik Burmester
87169480a1 chore 2026-01-16 08:32:05 +01:00
Fredrik Burmester
bd9467b09e fix: remove music provider for tv 2026-01-16 08:32:02 +01:00
Fredrik Burmester
6216e7fdb7 fix: items content for tv 2026-01-16 08:31:53 +01:00
Fredrik Burmester
6d2e897c9f fix: badge for tv 2026-01-16 08:31:44 +01:00
Fredrik Burmester
ad5148daad fix: login stuff for tv 2026-01-16 08:31:37 +01:00
Fredrik Burmester
c1e12d5898 fix: login page for tv 2026-01-16 08:30:50 +01:00
Fredrik Burmester
7416c8297a fix: hide music bar 2026-01-16 08:30:40 +01:00
Fredrik Burmester
9727bec7ab fix: hide header buttons 2026-01-16 08:30:33 +01:00
Fredrik Burmester
6ba767a848 fix: tvos 2026-01-16 08:04:23 +01:00
Fredrik Burmester
4ad103acb6 fix: conditionals for tv to build / run 2026-01-16 08:04:09 +01:00
renovate[bot]
36304ad58e chore(deps): Update dependency react-native-nitro-modules to v0.33.1 (#1340)
Some checks failed
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 16:48:51 +01:00
renovate[bot]
baeb83581e chore(deps): Update actions/setup-node action to v6.2.0 (#1372)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:22:19 +01:00
Fredrik Burmester
05b7a4c50d fix: downloads should work when connecting through QC 2026-01-15 07:54:08 +01:00
Fredrik Burmester
28b67f3ad6 fix(mpv): handle audio track selection for transcoded streams on iOS 2026-01-15 07:53:15 +01:00
Chris
51cd195bfe Reverting Jellyseer/Seer logo update
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
Final logo not yet released, holding off until further updates
2026-01-14 20:23:39 +01:00
Chris
0184e266a0 Update Jellyseer logo to Seer logo
Updated logo in order to reflecting the new branding
2026-01-14 20:17:25 +01:00
252 changed files with 26434 additions and 2524 deletions

View File

@@ -3,7 +3,7 @@
## Project Overview ## Project Overview
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native).
It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Seerr APIs, It supports mobile (iOS/Android) and TV platforms, integrates with Jellyfin and Jellyseerr APIs,
and provides seamless media streaming with offline capabilities and Chromecast support. and provides seamless media streaming with offline capabilities and Chromecast support.
## Main Technologies ## Main Technologies
@@ -40,30 +40,9 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- `scripts/` Automation scripts (Node.js, Bash) - `scripts/` Automation scripts (Node.js, Bash)
- `plugins/` Expo/Metro plugins - `plugins/` Expo/Metro plugins
## Code Quality Standards ## Coding Standards
**CRITICAL: Code must be production-ready, reliable, and maintainable**
### Type Safety
- Use TypeScript for ALL files (no .js files) - Use TypeScript for ALL files (no .js files)
- **NEVER use `any` type** - use proper types, generics, or `unknown` with type guards
- Use `@ts-expect-error` with detailed comments only when necessary (e.g., library limitations)
- When facing type issues, create proper type definitions and helper functions instead of using `any`
- Use type assertions (`as`) only as a last resort with clear documentation explaining why
- For Expo Router navigation: prefer string URLs with `URLSearchParams` over object syntax to avoid type conflicts
- Enable and respect strict TypeScript compiler options
- Define explicit return types for functions
- Use discriminated unions for complex state
### Code Reliability
- Implement comprehensive error handling with try-catch blocks
- Validate all external inputs (API responses, user input, query params)
- Handle edge cases explicitly (empty arrays, null, undefined)
- Use optional chaining (`?.`) and nullish coalescing (`??`) appropriately
- Add runtime checks for critical operations
- Implement proper loading and error states in components
### Best Practices
- Use descriptive English names for variables, functions, and components - Use descriptive English names for variables, functions, and components
- Prefer functional React components with hooks - Prefer functional React components with hooks
- Use Jotai atoms for global state management - Use Jotai atoms for global state management
@@ -71,10 +50,8 @@ and provides seamless media streaming with offline capabilities and Chromecast s
- Follow BiomeJS formatting and linting rules - Follow BiomeJS formatting and linting rules
- Use `const` over `let`, avoid `var` entirely - Use `const` over `let`, avoid `var` entirely
- Implement proper error boundaries - Implement proper error boundaries
- Use React.memo() for performance optimization when needed - Use React.memo() for performance optimization
- Handle both mobile and TV navigation patterns - Handle both mobile and TV navigation patterns
- Write self-documenting code with clear intent
- Add comments only when code complexity requires explanation
## API Integration ## API Integration
@@ -108,18 +85,6 @@ Exemples:
- `fix(auth): handle expired JWT tokens` - `fix(auth): handle expired JWT tokens`
- `chore(deps): update Jellyfin SDK` - `chore(deps): update Jellyfin SDK`
## Internationalization (i18n)
- **Primary workflow**: Always edit `translations/en.json` for new translation keys or updates
- **Translation files** (ar.json, ca.json, cs.json, de.json, etc.):
- **NEVER add or remove keys** - Crowdin manages the key structure
- **Editing translation values is safe** - Bidirectional sync handles merges
- Prefer letting Crowdin translators update values, but direct edits work if needed
- **Crowdin workflow**:
- New keys added to `en.json` sync to Crowdin automatically
- Approved translations sync back to language files via GitHub integration
- The source of truth is `en.json` for structure, Crowdin for translations
## Special Instructions ## Special Instructions
- Prioritize cross-platform compatibility (mobile + TV) - Prioritize cross-platform compatibility (mobile + TV)

View File

@@ -107,7 +107,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with: with:
node-version: '24.x' node-version: '24.x'
cache: 'npm' cache: 'npm'

3
.gitignore vendored
View File

@@ -50,8 +50,6 @@ 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
@@ -73,3 +71,4 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts # ios:unsigned-build Artifacts
build/ build/
.claude/settings.local.json

212
CLAUDE.md
View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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 Seerr 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.
## Development Commands ## Development Commands
@@ -134,3 +134,213 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- 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 overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
### TV Component Rendering Pattern
**IMPORTANT**: The `.tv.tsx` file suffix only works for **pages** in the `app/` directory (resolved by Expo Router). It does NOT work for components - Metro bundler doesn't resolve platform-specific suffixes for component imports.
**Pattern for TV-specific 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
### TV Option Selector Pattern (Dropdowns/Multi-select)
For dropdown/select components on TV, use a **bottom sheet with horizontal scrolling**. This pattern is ideal for TV because:
- Horizontal scrolling is natural for TV remotes (left/right D-pad)
- Bottom sheet takes minimal screen space
- Focus-based navigation works reliably
**Key implementation details:**
1. **Use absolute positioning instead of Modal** - React Native's `Modal` breaks the TV focus chain. Use an absolutely positioned `View` overlay instead:
```typescript
<View style={{
position: "absolute",
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}>
<BlurView intensity={80} tint="dark" style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24 }}>
{/* Content */}
</BlurView>
</View>
```
2. **Horizontal ScrollView with focusable cards**:
```typescript
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingHorizontal: 48, paddingVertical: 10, gap: 12 }}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
hasTVPreferredFocus={index === selectedIndex}
onPress={() => { onSelect(option.value); onClose(); }}
// ...
/>
))}
</ScrollView>
```
3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`:
```typescript
<Pressable
onPress={onPress}
onFocus={() => { setFocused(true); animateTo(1.05); }}
onBlur={() => { setFocused(false); animateTo(1); }}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View style={{ transform: [{ scale }], backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)" }}>
<Text style={{ color: focused ? "#000" : "#fff" }}>{label}</Text>
</Animated.View>
</Pressable>
```
4. **Add padding for scale animations** - When items scale on focus, add enough padding (`overflow: "visible"` + `paddingVertical`) so scaled items don't clip.
**Reference implementation**: See `TVOptionSelector` and `TVOptionCard` in `components/ItemContent.tv.tsx`
### TV Focus Management for Overlays/Modals
**CRITICAL**: When displaying overlays (bottom sheets, modals, dialogs) on TV, you must explicitly disable focus on all background elements. Without this, the TV focus engine will rapidly switch between overlay and background elements, causing a focus loop that freezes navigation.
**Solution**: Add a `disabled` prop to every focusable component and pass `disabled={isModalOpen}` when an overlay is visible:
```typescript
// 1. Track modal state
const [openModal, setOpenModal] = useState<ModalType | null>(null);
const isModalOpen = openModal !== null;
// 2. Each focusable component accepts disabled prop
const TVFocusableButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ onPress, disabled }) => (
<Pressable
onPress={onPress}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={isFirst && !disabled}
>
{/* content */}
</Pressable>
);
// 3. Pass disabled to all background components when modal is open
<TVFocusableButton onPress={handlePress} disabled={isModalOpen} />
```
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
### 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.

100
README.md
View File

@@ -22,75 +22,58 @@
&nbsp; &nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="20%"> <img src="./assets/images/screenshots/screenshot2.png" width="20%">
&nbsp; &nbsp;
<img src="./assets/images/seerr.PNG" width="21%"> <img src="./assets/images/jellyseerr.PNG" width="21%">
</p> </p>
## 🌟 Features ## 🌟 Features
### 🎬 Media Playback - 🚀 **Skip Intro / Credits Support**: Lets you quickly skip intros and credits during playback
- 🚀 **Skip Intro / Credits**: Automatically skip intros and credits during playback - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking
- 🖼️ **Trickplay Images**: Chapter previews with thumbnails when seeking - 📥 **Download media**: Save your media locally and watch it offline
- 🎵 **Music Library**: Full support for music playback with playlists and queue management - ⚙️ **Settings management**: Manage app configurations for all users through our plugin
- 📺 **Live TV**: Watch and record live television streams - 🤖 **Seerr (formerly Jellyseerr) integration**: Request media directly in the app
- 👁️ **Sessions view:** View all active sessions currently streaming on your server
- 📡 **Chromecast**: Cast your media to any Chromecast-enabled device - 📡 **Chromecast**: Cast your media to any Chromecast-enabled device
- 🎥 **MPV Player**: Powerful open-source player with wide format support
### 📱 Media Management ## 🧪 Experimental Features
- 📥 **Download Media**: Save movies, shows, and music locally for offline viewing
-**Favorites**: Quick access to your favorite content
- 📋 **Watchlists**: Create and manage custom watchlists with Streamystats integration
- 🔖 **Continue Watching**: Pick up right where you left off
- 🎯 **Next Up**: Smart suggestions for your next episode
### ⚙️ Advanced Features Streamyfin offers exciting experimental features such as media downloading and Chromecast support. These features are under active development, and your feedback and patience help us make them even better.
- 🤖 **Seerr Integration**: Request new media directly in the app
- 🔍 **Smart Search**: Powerful search with Marlin Search and Streamystats support
- 👁️ **Active Sessions**: View all active streams on your server
- 🌐 **Multi-Language**: Available in 20+ languages with Crowdin integration
- 🎨 **Customizable**: Personalize your home screen and settings
- 🔌 **Plugin System**: Centralized settings sync across all devices via Jellyfin plugin
## 🧩 How It Works ### 📥 Downloading
### 📥 Downloads
Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode. Downloading works by using FFmpeg to convert an HLS stream into a video file on your device. This lets you download and watch any content that you can stream. The conversion is handled in real time by Jellyfin on the server during the download. While this may take a bit longer, it ensures compatibility with any file your server can transcode.
### 🧩 Streamyfin Plugin ### 🧩 Streamyfin Plugin
The Jellyfin Plugin for Streamyfin synchronizes settings across all your devices and users. Install it on your Jellyfin server to enable: The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
- Automatic Seerr login with no user input required - Automatic Seerr login with no user input required
- Default language preferences for audio and subtitles - Set your preferred default languages
- Configure download settings and search providers (Marlin, Streamystats) - Configure download method and search provider
- Customize your home screen layout and sections - Personalize your home screen
- Centralized configuration management
- And much more - And much more
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) [Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
### 📡 Chromecast
Chromecast support is currently under development. Video casting is already available, and we're actively working on adding subtitle support and additional features.
### 🎬 MPV Player ### 🎬 MPV Player
Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback. Streamyfin uses [MPV](https://mpv.io/) as its primary video player on all platforms, powered by [MPVKit](https://github.com/mpvkit/MPVKit). MPV is a powerful, open-source media player known for its wide format support and high-quality playback.
Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin. Thanks to [@Alexk2309](https://github.com/Alexk2309) for the hard work building the native MPV module in Streamyfin.
### 🎵 Music Library ### 🔍 Jellysearch
Full music library support with playlists, queue management, background playback, and offline downloads. [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) works with Streamyfin
### 🔍 Search Providers > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
Streamyfin supports multiple search providers:
- **Marlin Search**: Fast semantic search for your Jellyfin library
- **Streamystats**: Advanced statistics and personalized recommendations
- **Jellysearch**: Fast full-text search proxy ([Jellysearch](https://gitlab.com/DomiStyle/jellysearch))
## 🛣️ Roadmap ## 🛣️ Roadmap
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests. Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
## 📥 Download Streamyfin ## 📥 Download Streamyfin
@@ -130,13 +113,13 @@ You can contribute translations directly on our [Crowdin project page](https://c
### 👨‍💻 Development Info ### 👨‍💻 Development Info
1. Use Node.js `>20` 1. Use node `>20`
2. Install dependencies: `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have Xcode and/or Android Studio installed ([Expo setup guide](https://docs.expo.dev/workflow/android-studio-emulator/)) 3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
- If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once - If iOS builds fail with `missing Metal Toolchain` (KSPlayer shaders), run `npm run ios:install-metal-toolchain` once
4. Install the [BiomeJS extension](https://biomejs.dev/) in your IDE 4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
5. Run `npm run prebuild` 4. run `npm run prebuild`
6. Create an Expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app 5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.
@@ -154,20 +137,10 @@ Need assistance or have any questions?
## ❓ FAQ ## ❓ FAQ
1. **Q: Why can't I see my libraries in Streamyfin?** 1. Q: Why can't I see my libraries in Streamyfin?
A: Ensure your Jellyfin server is running a recent version (10.10.0+) and that you have proper permissions to access the libraries. A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only
2. Q: Why can't I see my music library?
2. **Q: How do I enable downloads?** A: We don't currently support music and are unlikely to support music in the near future
A: Downloads use FFmpeg to convert HLS streams. Ensure your server has transcoding enabled and sufficient resources.
3. **Q: Does Streamyfin support subtitles?**
A: Yes, with full customization including size, color, position, and automatic language selection.
4. **Q: Can I use Streamyfin on Apple TV or Android TV?**
A: Yes, Streamyfin has dedicated TV builds optimized for remote control navigation. Please note that TV platforms are currently in early development and not very stable. Android TV is currently the most reliable platform for testing.
5. **Q: How do I set up Seerr integration?**
A: Go to Settings → Plugins → Seerr, enter your server URL and Jellyfin credentials.
## 📝 Credits ## 📝 Credits
@@ -281,9 +254,7 @@ A special mention to the following people and projects for their contributions:
## 📄 License ## 📄 License
Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0). Streamyfin is licensed under the Mozilla Public License 2.0 (MPL-2.0).
This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code. This means you are free to use, modify, and distribute this software. The MPL-2.0 is a copyleft license that allows for more flexibility in combining the software with proprietary code.
Key points of the MPL-2.0: Key points of the MPL-2.0:
- You can use the software for any purpose - You can use the software for any purpose
@@ -292,13 +263,10 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files - You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses - Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license - MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository
For the full text of the license, please see the LICENSE file in this repository.
## ⚠️ Disclaimer ## ⚠️ Disclaimer
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels. Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions, support requests, or references to piracy, as well as any tools, software, or websites related to piracy, are strictly prohibited across all our channels.
## 🤝 Sponsorship ## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster).

View File

@@ -23,7 +23,8 @@
}, },
"UISupportsTrueScreenSizeOnMac": true, "UISupportsTrueScreenSizeOnMac": true,
"UIFileSharingEnabled": true, "UIFileSharingEnabled": true,
"LSSupportsOpeningDocumentsInPlace": true "LSSupportsOpeningDocumentsInPlace": true,
"AVInitialRouteSharingPolicy": "LongFormAudio"
}, },
"config": { "config": {
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
@@ -55,7 +56,23 @@
"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",
@@ -121,6 +138,7 @@
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.js"],
["./plugins/withTVOSAppIcon.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.OS !== "ios", headerShown: !Platform.isTV,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.custom_links"), headerTitle: t("tabs.custom_links"),
headerBlurEffect: "none", headerBlurEffect: "none",

View File

@@ -2,6 +2,7 @@ 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 } 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() {
@@ -15,6 +16,10 @@ 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: true, headerShown: !Platform.isTV,
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: true, headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -81,6 +81,7 @@ 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", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -99,6 +100,7 @@ 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,
@@ -117,6 +119,7 @@ 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,
@@ -135,6 +138,7 @@ 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,
@@ -153,6 +157,7 @@ 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,
@@ -171,6 +176,7 @@ 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,
@@ -189,6 +195,7 @@ 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,
@@ -207,6 +214,7 @@ 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,
@@ -222,9 +230,10 @@ export default function IndexLayout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/plugins/seerr/page' name='settings/plugins/jellyseerr/page'
options={{ options={{
title: "Seerr", title: "Jellyseerr",
headerShown: !Platform.isTV,
headerBlurEffect: "none", headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -243,6 +252,7 @@ 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,
@@ -261,6 +271,7 @@ 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,
@@ -279,6 +290,7 @@ 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,
@@ -297,6 +309,7 @@ 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,
@@ -315,6 +328,7 @@ 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,
@@ -341,7 +355,7 @@ export default function IndexLayout() {
<Feather name='chevron-left' size={28} color='white' /> <Feather name='chevron-left' size={28} color='white' />
</Pressable> </Pressable>
), ),
headerShown: true, headerShown: !Platform.isTV,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -14,7 +14,11 @@ 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";
export default function settings() { // TV-specific settings component
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);
@@ -104,8 +108,17 @@ export default function settings() {
</ListGroup> </ListGroup>
</View> </View>
{!Platform.isTV && <StorageSettings />} <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

@@ -0,0 +1,385 @@
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import type { TVOptionItem } from "@/components/tv";
import {
TVLogoutButton,
TVSectionHeader,
TVSettingsOptionButton,
TVSettingsRow,
TVSettingsStepper,
TVSettingsTextInput,
TVSettingsToggle,
} from "@/components/tv";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings } = useSettings();
const { logout } = useJellyfin();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
settings.openSubtitlesApiKey || "",
);
const currentAudioTranscode =
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
const currentSubtitleMode =
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
// 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],
);
// 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]);
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: 42,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("home.settings.settings_title")}
</Text>
{/* 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 }),
})
}
isFirst
/>
{/* 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.subtitleSize / 100}
onDecrease={() => {
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
onIncrease={() => {
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
updateSettings({ subtitleSize: Math.round(newValue * 100) });
}}
formatValue={(v) => `${v.toFixed(1)}x`}
/>
{/* MPV Subtitles Section */}
<TVSectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
onDecrease={() => {
const newValue = Math.max(
0.5,
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
);
updateSettings({
mpvSubtitleScale: Math.round(newValue * 10) / 10,
});
}}
onIncrease={() => {
const newValue = Math.min(
2.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: 14,
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: 12,
marginTop: 8,
marginLeft: 8,
}}
>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<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 })}
/>
{/* 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>
</View>
);
}

View File

@@ -71,7 +71,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>

View File

@@ -60,7 +60,7 @@ export default function page() {
))} ))}
</ListGroup> </ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'> <Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_libraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

@@ -1,10 +1,10 @@
import { ScrollView } from "react-native"; import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { SeerrSettings } from "@/components/settings/Seerr"; 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();
@@ -17,10 +17,10 @@ export default function Page() {
}} }}
> >
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.seerrServerUrl?.locked === true} disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='px-4' className='px-4'
> >
<SeerrSettings /> <JellyseerrSettings />
</DisabledSetting> </DisabledSetting>
</ScrollView> </ScrollView>
); );

View File

@@ -3,9 +3,8 @@ 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 { View } from "react-native"; import { Platform, View } from "react-native";
import Animated, { import Animated, {
runOnJS,
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
withTiming, withTiming,
@@ -15,6 +14,10 @@ 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();
@@ -24,7 +27,11 @@ 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 { data: item, isError } = useItemQuery(id, isOffline, undefined, [ const {
data: item,
isError,
isLoading,
} = useItemQuery(id, isOffline, undefined, [
ItemFields.MediaSources, ItemFields.MediaSources,
ItemFields.MediaSourceCount, ItemFields.MediaSourceCount,
ItemFields.MediaStreams, ItemFields.MediaStreams,
@@ -40,33 +47,14 @@ const Page: React.FC = () => {
}; };
}); });
const fadeOut = (callback: any) => { // Fast fade out when item loads (no setTimeout delay)
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) {
fadeOut(() => {}); opacity.value = withTiming(0, { duration: 150 });
} else { } else {
fadeIn(() => {}); opacity.value = withTiming(1, { duration: 150 });
} }
}, [item]); }, [item, opacity]);
if (isError) if (isError)
return ( return (
@@ -78,14 +66,27 @@ 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 */}
<ItemContent
item={item}
itemWithSources={itemWithSources}
isLoading={isLoading}
/>
{/* Skeleton overlay - fades out when content loads */}
{!item && (
<Animated.View <Animated.View
pointerEvents={"none"} pointerEvents={"none"}
style={[animatedStyle]} style={[animatedStyle]}
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black' className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
> >
{Platform.isTV && ItemContentSkeletonTV ? (
<ItemContentSkeletonTV />
) : (
<View style={{ paddingHorizontal: 16, width: "100%" }}>
<View <View
style={{ style={{
height: item?.Type === "Episode" ? 300 : 450, height: 450,
}} }}
className='bg-transparent rounded-lg mb-4 w-full' className='bg-transparent rounded-lg mb-4 w-full'
/> />
@@ -101,8 +102,10 @@ const Page: React.FC = () => {
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' /> <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-12 bg-neutral-900 rounded-lg w-full mb-2' />
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' /> <View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
</View>
)}
</Animated.View> </Animated.View>
{item && <ItemContent item={item} itemWithSources={itemWithSources} />} )}
</View> </View>
</OfflineModeProvider> </OfflineModeProvider>
); );

View File

@@ -3,9 +3,9 @@ import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import SeerrPoster from "@/components/posters/SeerrPoster"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useSeerr } from "@/hooks/useSeerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { import {
type MovieResult, type MovieResult,
@@ -13,9 +13,9 @@ 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 CompanyPage() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, image, type } = local as unknown as { const { companyId, image, type } = local as unknown as {
companyId: string; companyId: string;
@@ -25,12 +25,12 @@ export default function CompanyPage() {
}; };
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery({
queryKey: ["seerr", "company", type, companyId], queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
}; };
return seerrApi?.discover( return jellyseerrApi?.discover(
`${ `${
Number(type) === DiscoverSliderType.NETWORKS Number(type) === DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK ? Endpoints.DISCOVER_TV_NETWORK
@@ -39,7 +39,7 @@ export default function CompanyPage() {
params, params,
); );
}, },
enabled: !!seerrApi && !!companyId, enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -53,24 +53,25 @@ export default function CompanyPage() {
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap( .flatMap(
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [], (p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
), ),
"id", "id",
) ?? [], ) ?? [],
[data, isSeerrMovieOrTvResult], [data],
); );
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
seerrApi jellyseerrApi
? flatData.map((r) => ? flatData.map((r) =>
seerrApi.imageProxy( jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath, (r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
) )
: [], : [],
[seerrApi, flatData], [jellyseerrApi, flatData],
); );
return ( return (
@@ -91,7 +92,7 @@ export default function CompanyPage() {
key={companyId} key={companyId}
className='bottom-1 w-1/2' className='bottom-1 w-1/2'
source={{ source={{
uri: seerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER), uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit='contain' contentFit='contain'
@@ -100,7 +101,7 @@ export default function CompanyPage() {
}} }}
/> />
} }
renderItem={(item, _index) => <SeerrPoster item={item} />} renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/> />
); );
} }

View File

@@ -3,15 +3,15 @@ import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import SeerrPoster from "@/components/posters/SeerrPoster"; import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useSeerr } from "@/hooks/useSeerr"; 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 GenrePage() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as { const { genreId, name, type } = local as unknown as {
genreId: string; genreId: string;
@@ -20,21 +20,21 @@ export default function GenrePage() {
}; };
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "genre", type, genreId], queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
const params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
genre: genreId, genre: genreId,
}; };
return seerrApi?.discover( return jellyseerrApi?.discover(
type === DiscoverSliderType.MOVIE_GENRES type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES ? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV, : Endpoints.DISCOVER_TV,
params, params,
); );
}, },
enabled: !!seerrApi && !!genreId, enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
@@ -48,7 +48,8 @@ export default function GenrePage() {
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap( .flatMap(
(p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r)) ?? [], (p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
), ),
"id", "id",
) ?? [], ) ?? [],
@@ -57,12 +58,15 @@ export default function GenrePage() {
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
seerrApi jellyseerrApi
? flatData.map((r) => ? flatData.map((r) =>
seerrApi.imageProxy(r.backdropPath, "w1920_and_h800_multi_faces"), jellyseerrApi.imageProxy(
r.backdropPath,
"w1920_and_h800_multi_faces",
),
) )
: [], : [],
[seerrApi, flatData], [jellyseerrApi, flatData],
); );
return ( return (
@@ -87,7 +91,7 @@ export default function GenrePage() {
{name} {name}
</Text> </Text>
} }
renderItem={(item, _index) => <SeerrPoster item={item} />} renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/> />
); );
} }

View File

@@ -18,18 +18,19 @@ import { toast } from "sonner-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
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";
import { SeerrRatings } from "@/components/Ratings"; import { JellyserrRatings } from "@/components/Ratings";
import Cast from "@/components/seerr/Cast"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import DetailFacts from "@/components/seerr/DetailFacts";
import RequestModal from "@/components/seerr/RequestModal";
import SeerrSeasons from "@/components/series/SeerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { import {
type IssueType, type IssueType,
@@ -52,7 +53,8 @@ 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";
const Page: React.FC = () => { // Mobile page component
const MobilePage: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -68,7 +70,7 @@ const Page: React.FC = () => {
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>; } & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation(); const navigation = useNavigation();
const { seerrApi, seerrUser, requestMedia } = useSeerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
@@ -83,8 +85,8 @@ const Page: React.FC = () => {
isLoading, isLoading,
refetch, refetch,
} = useQuery({ } = useQuery({
enabled: !!seerrApi && !!result && !!result.id, enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["seerr", "detail", mediaType, result.id], queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
@@ -93,18 +95,21 @@ const Page: React.FC = () => {
refetchInterval: 0, refetchInterval: 0,
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(result.id!) ? jellyseerrApi?.movieDetails(result.id!)
: seerrApi?.tvDetails(result.id!); : jellyseerrApi?.tvDetails(result.id!);
}, },
}); });
const [canRequest, hasAdvancedRequestPermission] = const [canRequest, hasAdvancedRequestPermission] =
useSeerrCanRequest(details); useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => { const canManageRequests = useMemo(() => {
if (!seerrUser) return false; if (!jellyseerrUser) return false;
return hasPermission(Permission.MANAGE_REQUESTS, seerrUser.permissions); return hasPermission(
}, [seerrUser]); Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => { const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find( return details?.mediaInfo?.requests?.find(
@@ -116,27 +121,27 @@ const Page: React.FC = () => {
if (!pendingRequest?.id) return; if (!pendingRequest?.id) return;
try { try {
await seerrApi?.approveRequest(pendingRequest.id); await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_approved")); toast.success(t("jellyseerr.toasts.request_approved"));
refetch(); refetch();
} catch (error) { } catch (error) {
toast.error(t("seerr.toasts.failed_to_approve_request")); toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
console.error("Failed to approve request:", error); console.error("Failed to approve request:", error);
} }
}, [seerrApi, pendingRequest, refetch, t]); }, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => { const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return; if (!pendingRequest?.id) return;
try { try {
await seerrApi?.declineRequest(pendingRequest.id); await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("seerr.toasts.request_declined")); toast.success(t("jellyseerr.toasts.request_declined"));
refetch(); refetch();
} catch (error) { } catch (error) {
toast.error(t("seerr.toasts.failed_to_decline_request")); toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
console.error("Failed to decline request:", error); console.error("Failed to decline request:", error);
} }
}, [seerrApi, pendingRequest, refetch, t]); }, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
@@ -151,7 +156,7 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) { if (result.id && issueType && issueMessage && details) {
seerrApi jellyseerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) ?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => { .then(() => {
setIssueType(undefined); setIssueType(undefined);
@@ -159,7 +164,7 @@ const Page: React.FC = () => {
bottomSheetModalRef?.current?.close(); bottomSheetModalRef?.current?.close();
}); });
} }
}, [seerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleIssueModalDismiss = useCallback(() => { const handleIssueModalDismiss = useCallback(() => {
setIssueTypeDropdownOpen(false); setIssueTypeDropdownOpen(false);
@@ -211,7 +216,7 @@ const Page: React.FC = () => {
const issueTypeOptionGroups = useMemo( const issueTypeOptionGroups = useMemo(
() => [ () => [
{ {
title: t("seerr.types"), title: t("jellyseerr.types"),
options: Object.entries(IssueTypeName) options: Object.entries(IssueTypeName)
.reverse() .reverse()
.map(([key, value]) => ({ .map(([key, value]) => ({
@@ -262,7 +267,7 @@ const Page: React.FC = () => {
height: "100%", height: "100%",
}} }}
source={{ source={{
uri: seerrApi?.imageProxy( uri: jellyseerrApi?.imageProxy(
result.backdropPath, result.backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
@@ -292,7 +297,7 @@ const Page: React.FC = () => {
<View className='px-4'> <View className='px-4'>
<View className='flex flex-row justify-between w-full'> <View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'> <View className='flex flex-col w-56'>
<SeerrRatings <JellyserrRatings
result={ result={
result as result as
| MovieResult | MovieResult
@@ -327,7 +332,7 @@ const Page: React.FC = () => {
/> />
) : canRequest ? ( ) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'> <Button color='purple' onPress={request} className='mt-4'>
{t("seerr.request_button")} {t("jellyseerr.request_button")}
</Button> </Button>
) : ( ) : (
details?.mediaInfo?.jellyfinMediaId && ( details?.mediaInfo?.jellyfinMediaId && (
@@ -350,7 +355,7 @@ const Page: React.FC = () => {
}} }}
> >
<Text className='text-sm'> <Text className='text-sm'>
{t("seerr.report_issue_button")} {t("jellyseerr.report_issue_button")}
</Text> </Text>
</Button> </Button>
)} )}
@@ -386,12 +391,12 @@ const Page: React.FC = () => {
<View className='flex flex-row items-center space-x-2'> <View className='flex flex-row items-center space-x-2'>
<Ionicons name='person-outline' size={16} color='#9CA3AF' /> <Ionicons name='person-outline' size={16} color='#9CA3AF' />
<Text className='text-sm text-neutral-400'> <Text className='text-sm text-neutral-400'>
{t("seerr.requested_by", { {t("jellyseerr.requested_by", {
user: user:
pendingRequest.requestedBy?.displayName || pendingRequest.requestedBy?.displayName ||
pendingRequest.requestedBy?.username || pendingRequest.requestedBy?.username ||
pendingRequest.requestedBy?.jellyfinUsername || pendingRequest.requestedBy?.jellyfinUsername ||
t("seerr.unknown_user"), t("jellyseerr.unknown_user"),
})} })}
</Text> </Text>
</View> </View>
@@ -412,7 +417,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("seerr.approve")}</Text> <Text className='text-sm'>{t("jellyseerr.approve")}</Text>
</Button> </Button>
<Button <Button
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100' className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
@@ -430,7 +435,7 @@ const Page: React.FC = () => {
borderStyle: "solid", borderStyle: "solid",
}} }}
> >
<Text className='text-sm'>{t("seerr.decline")}</Text> <Text className='text-sm'>{t("jellyseerr.decline")}</Text>
</Button> </Button>
</View> </View>
</View> </View>
@@ -439,7 +444,7 @@ const Page: React.FC = () => {
</View> </View>
{mediaType === MediaType.TV && ( {mediaType === MediaType.TV && (
<SeerrSeasons <JellyseerrSeasons
isLoading={isLoading || isFetching} isLoading={isLoading || isFetching}
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} refetch={refetch}
@@ -488,13 +493,13 @@ const Page: React.FC = () => {
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View> <View>
<Text className='font-bold text-2xl text-neutral-100'> <Text className='font-bold text-2xl text-neutral-100'>
{t("seerr.whats_wrong")} {t("jellyseerr.whats_wrong")}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 items-start'> <View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col w-full'> <View className='flex flex-col w-full'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("seerr.issue_type")} {t("jellyseerr.issue_type")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={issueTypeOptionGroups} groups={issueTypeOptionGroups}
@@ -503,11 +508,11 @@ const Page: React.FC = () => {
<Text numberOfLines={1}> <Text numberOfLines={1}>
{issueType {issueType
? IssueTypeName[issueType] ? IssueTypeName[issueType]
: t("seerr.select_an_issue")} : t("jellyseerr.select_an_issue")}
</Text> </Text>
</View> </View>
} }
title={t("seerr.types")} title={t("jellyseerr.types")}
open={issueTypeDropdownOpen} open={issueTypeDropdownOpen}
onOpenChange={setIssueTypeDropdownOpen} onOpenChange={setIssueTypeDropdownOpen}
/> />
@@ -519,7 +524,7 @@ const Page: React.FC = () => {
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode='always' clearButtonMode='always'
placeholder={t("seerr.describe_the_issue")} placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF' placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
@@ -529,7 +534,7 @@ const Page: React.FC = () => {
</View> </View>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'> <Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("seerr.submit_button")} {t("jellyseerr.submit_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>
@@ -539,4 +544,12 @@ const Page: 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

@@ -5,27 +5,31 @@ import { orderBy, uniqBy } from "lodash";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import SeerrPoster from "@/components/posters/SeerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import ParallaxSlideShow from "@/components/seerr/ParallaxSlideShow"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useSeerr } from "@/hooks/useSeerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
export default function PersonPage() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { seerrApi, seerrLocale: locale } = useSeerr(); const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["seerr", "person", personId], queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({ queryFn: async () => ({
details: await seerrApi?.personDetails(personId), details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await seerrApi?.personCombinedCredits(personId), combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
}), }),
enabled: !!seerrApi && !!personId, enabled: !!jellyseerrApi && !!personId,
}); });
const castedRoles: PersonCreditCast[] = useMemo( const castedRoles: PersonCreditCast[] = useMemo(
@@ -42,19 +46,22 @@ export default function PersonPage() {
); );
const backdrops = useMemo( const backdrops = useMemo(
() => () =>
seerrApi jellyseerrApi
? castedRoles.map((c) => ? castedRoles.map((c) =>
seerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"), jellyseerrApi.imageProxy(
c.backdropPath,
"w1920_and_h800_multi_faces",
),
) )
: [], : [],
[seerrApi, data?.combinedCredits], [jellyseerrApi, data?.combinedCredits],
); );
return ( return (
<ParallaxSlideShow <ParallaxSlideShow
data={castedRoles} data={castedRoles}
images={backdrops} images={backdrops}
listHeader={t("seerr.appearances")} listHeader={t("jellyseerr.appearances")}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
logo={ logo={
<Image <Image
@@ -62,7 +69,7 @@ export default function PersonPage() {
id={data?.details?.id.toString()} id={data?.details?.id.toString()}
className='rounded-full bottom-1' className='rounded-full bottom-1'
source={{ source={{
uri: seerrApi?.imageProxy( uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath, data?.details?.profilePath,
"w600_and_h600_bestv2", "w600_and_h600_bestv2",
), ),
@@ -79,13 +86,16 @@ export default function PersonPage() {
<> <>
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text> <Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'> <Text className='opacity-50'>
{t("seerr.born")}{" "} {t("jellyseerr.born")}{" "}
{data?.details?.birthday && {data?.details?.birthday &&
new Date(data.details.birthday).toLocaleDateString(locale, { new Date(data.details.birthday).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
})}{" "} },
)}{" "}
| {data?.details?.placeOfBirth} | {data?.details?.placeOfBirth}
</Text> </Text>
</> </>
@@ -93,7 +103,7 @@ export default function PersonPage() {
MainContent={() => ( MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' /> <OverviewText text={data?.details?.biography} className='mt-4' />
)} )}
renderItem={(item, _index) => <SeerrPoster item={item} />} renderItem={(item, _index) => <JellyseerrPoster item={item} />}
/> />
); );
} }

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 { View } from "react-native"; import { Platform, 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,6 +15,7 @@ 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";
@@ -23,6 +24,16 @@ 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,6 +14,7 @@ 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";
@@ -159,6 +160,19 @@ 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

@@ -1,3 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import type { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
@@ -11,19 +12,44 @@ 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 { BlurView } from "expo-blur";
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,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FlatList, useWindowDimensions, View } from "react-native"; import {
Animated,
Easing,
FlatList,
Platform,
Pressable,
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 { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
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 MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
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";
@@ -49,6 +75,280 @@ import {
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
// TV Filter Types and Components
type TVFilterModalType =
| "genre"
| "year"
| "tags"
| "sortBy"
| "sortOrder"
| "filterBy"
| null;
interface TVFilterOption<T> {
label: string;
value: T;
selected: boolean;
}
const TVFilterOptionCard: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={{ position: "absolute", top: 8, right: 8 }}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
const TVFilterButton: React.FC<{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
hasActiveFilter?: boolean;
}> = ({
label,
value,
onPress,
hasTVPreferredFocus,
disabled,
hasActiveFilter,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.04);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={{ transform: [{ scale }] }}>
<View
style={{
backgroundColor: focused
? "#fff"
: hasActiveFilter
? "rgba(255, 255, 255, 0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderWidth: hasActiveFilter && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.4)",
}}
>
{label ? (
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
) : null}
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
const TVFilterSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVFilterOption<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
// Track initial focus index - only set once when modal opens
const initialFocusIndexRef = useRef<number | null>(null);
// Calculate initial focus index only once when visible becomes true
if (visible && initialFocusIndexRef.current === null) {
const idx = options.findIndex((o) => o.selected);
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
}
// Reset when modal closes
if (!visible) {
initialFocusIndexRef.current = null;
return null;
}
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View style={{ paddingVertical: 24 }}>
<Text
style={{
fontSize: 20,
fontWeight: "600",
color: "#fff",
paddingHorizontal: 48,
marginBottom: 16,
}}
>
{title}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVFilterOptionCard
key={String(option.value)}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
const Page = () => { const Page = () => {
const searchParams = useLocalSearchParams() as { const searchParams = useLocalSearchParams() as {
libraryId: string; libraryId: string;
@@ -79,6 +379,54 @@ const Page = () => {
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
// TV Filter modal state
const [openFilterModal, setOpenFilterModal] =
useState<TVFilterModalType>(null);
const isFilterModalOpen = openFilterModal !== null;
const isFiltersDisabled = isFilterModalOpen;
// 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)
@@ -162,6 +510,14 @@ const Page = () => {
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
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;
@@ -322,7 +678,38 @@ const Page = () => {
</View> </View>
</TouchableItemRouter> </TouchableItemRouter>
), ),
[orientation], [orientation, nrOfCols],
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[router, isFilterModalOpen],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -509,6 +896,156 @@ 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(
(): TVFilterOption<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(
(): TVFilterOption<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(
(): TVFilterOption<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(
(): TVFilterOption<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVFilterOption<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
const tvFilterByOptions = useMemo(
(): TVFilterOption<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
const handleGenreSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
[selectedGenres, setSelectedGenres],
);
const handleYearSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
[selectedYears, setSelectedYears],
);
const handleTagSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((t) => t !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
[selectedTags, setSelectedTags],
);
const handleFilterBySelect = useCallback(
(value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
[setFilter, _setFilterBy],
);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading) if (isLoading || isLibraryLoading)
@@ -518,6 +1055,8 @@ const Page = () => {
</View> </View>
); );
// Mobile return
if (!Platform.isTV) {
return ( return (
<FlashList <FlashList
key={orientation} key={orientation}
@@ -556,6 +1095,188 @@ const Page = () => {
)} )}
/> />
); );
}
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
return (
<View style={{ flex: 1 }}>
{/* Background content - disabled when modal is open */}
<View
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
focusable={!isFilterModalOpen}
isTVSelectable={!isFilterModalOpen}
pointerEvents={isFilterModalOpen ? "none" : "auto"}
accessibilityElementsHidden={isFilterModalOpen}
importantForAccessibility={
isFilterModalOpen ? "no-hide-descendants" : "auto"
}
>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
disabled={isFiltersDisabled}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("genre")}
hasTVPreferredFocus={!hasActiveFilters}
disabled={isFiltersDisabled}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
disabled={isFiltersDisabled}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<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("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols, isFilterModalOpen]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
{/* TV Filter Overlays */}
<TVFilterSelector
visible={openFilterModal === "genre"}
title={t("library.filters.genres")}
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "year"}
title={t("library.filters.years")}
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "tags"}
title={t("library.filters.tags")}
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortBy"}
title={t("library.filters.sort_by")}
options={tvSortByOptions}
onSelect={(value) => setSortBy([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortOrder"}
title={t("library.filters.sort_order")}
options={tvSortOrderOptions}
onSelect={(value) => setSortOrder([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
onSelect={handleFilterBySelect}
onClose={() => setOpenFilterModal(null)}
/>
</View>
);
}; };
export default React.memo(Page); export default React.memo(Page);

View File

@@ -1,109 +1,11 @@
import { import { Platform } from "react-native";
getUserLibraryApi, import { Libraries } from "@/components/library/Libraries";
getUserViewsApi, import { TVLibraries } from "@/components/library/TVLibraries";
} 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 index() { export default function LibrariesPage() {
const [api] = useAtom(apiAtom); if (Platform.isTV) {
const [user] = useAtom(userAtom); return <TVLibraries />;
const queryClient = useQueryClient();
const { settings } = useSettings();
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(); return <Libraries />;
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

@@ -33,17 +33,17 @@ export default function SearchLayout() {
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />
<Stack.Screen name='seerr/page' options={commonScreenOptions} /> <Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
<Stack.Screen <Stack.Screen
name='seerr/person/[personId]' name='jellyseerr/person/[personId]'
options={commonScreenOptions} options={commonScreenOptions}
/> />
<Stack.Screen <Stack.Screen
name='seerr/company/[companyId]' name='jellyseerr/company/[companyId]'
options={commonScreenOptions} options={commonScreenOptions}
/> />
<Stack.Screen <Stack.Screen
name='seerr/genre/[genreId]' name='jellyseerr/genre/[genreId]'
options={commonScreenOptions} options={commonScreenOptions}
/> />
</Stack> </Stack>

View File

@@ -7,8 +7,9 @@ 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 } from "expo-router"; import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -22,26 +23,35 @@ 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 { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { DiscoverFilters } from "@/components/search/DiscoverFilters"; 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 { import { TVSearchPage } from "@/components/search/TVSearchPage";
SeerrIndexPage,
SeerrSearchSort,
} from "@/components/seerr/SeerrIndexPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
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";
@@ -55,10 +65,12 @@ const exampleSearches = [
"The Mandalorian", "The Mandalorian",
]; ];
export default function SearchPage() { export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(search)";
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -93,11 +105,16 @@ export default function SearchPage() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const [seerrOrderBy, setSeerrOrderBy] = useState<SeerrSearchSort>( const [jellyseerrOrderBy, setJellyseerrOrderBy] =
SeerrSearchSort[SeerrSearchSort.DEFAULT] as unknown as SeerrSearchSort, useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
); );
const [seerrSortOrder, setSeerrSortOrder] = useState<"asc" | "desc">("desc"); const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin"; return settings?.searchEngine || "Jellyfin";
@@ -194,9 +211,7 @@ export default function SearchPage() {
return []; return [];
} }
const url = `${ const url = `${settings.marlinServerUrl}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
settings.marlinServerUrl
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type)) .map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`; .join("&includeItemTypes=")}`;
@@ -435,6 +450,179 @@ export default function SearchPage() {
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}
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'
@@ -445,31 +633,11 @@ export default function SearchPage() {
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 }}
> >
{seerrApi && ( {jellyseerrApi && (
<View className='pl-4 pr-4 flex flex-row'> <View className='pl-4 pr-4 flex flex-row'>
<SearchTabButtons <SearchTabButtons
searchType={searchType} searchType={searchType}
@@ -483,10 +651,10 @@ export default function SearchPage() {
<DiscoverFilters <DiscoverFilters
searchFilterId={searchFilterId} searchFilterId={searchFilterId}
orderFilterId={orderFilterId} orderFilterId={orderFilterId}
seerrOrderBy={seerrOrderBy} jellyseerrOrderBy={jellyseerrOrderBy}
setSeerrOrderBy={setSeerrOrderBy} setJellyseerrOrderBy={setJellyseerrOrderBy}
seerrSortOrder={seerrSortOrder} jellyseerrSortOrder={jellyseerrSortOrder}
setSeerrSortOrder={setSeerrSortOrder} setJellyseerrSortOrder={setJellyseerrSortOrder}
t={t} t={t}
/> />
)} )}
@@ -749,10 +917,10 @@ export default function SearchPage() {
/> />
</View> </View>
) : ( ) : (
<SeerrIndexPage <JellyserrIndexPage
searchQuery={debouncedSearch} searchQuery={debouncedSearch}
sortType={seerrOrderBy} sortType={jellyseerrOrderBy}
order={seerrSortOrder} order={jellyseerrSortOrder}
/> />
)} )}

View File

@@ -0,0 +1,21 @@
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

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

View File

@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Platform,
RefreshControl, RefreshControl,
TouchableOpacity, TouchableOpacity,
useWindowDimensions, useWindowDimensions,
@@ -16,9 +17,17 @@ 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 { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import {
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 MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { import {
@@ -32,6 +41,20 @@ 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 = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
export default function WatchlistDetailScreen() { export default function WatchlistDetailScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -47,6 +70,14 @@ export default function WatchlistDetailScreen() {
: undefined; : undefined;
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
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;
@@ -153,6 +184,37 @@ export default function WatchlistDetailScreen() {
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t], [removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
); );
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
};
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster
onPress={handlePress}
hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[router],
);
const renderItem = useCallback( const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => ( ({ item, index }: { item: BaseItemDto; index: number }) => (
<TouchableItemRouter <TouchableItemRouter
@@ -278,13 +340,14 @@ export default function WatchlistDetailScreen() {
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
contentContainerStyle={{ contentContainerStyle={{
paddingBottom: 24, paddingBottom: 24,
paddingLeft: insets.left, paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
paddingRight: insets.right, paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
}} }}
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} /> <RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
} }
renderItem={renderItem} renderItem={Platform.isTV ? renderTVItem : renderItem}
ItemSeparatorComponent={() => ( ItemSeparatorComponent={() => (
<View <View
style={{ style={{

View File

@@ -40,7 +40,7 @@ export default function WatchlistsLayout() {
name='[watchlistId]' name='[watchlistId]'
options={{ options={{
title: "", title: "",
headerShown: true, headerShown: !Platform.isTV,
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: true, headerShown: !Platform.isTV,
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: true, headerShown: !Platform.isTV,
headerStyle: { backgroundColor: "#171717" }, headerStyle: { backgroundColor: "#171717" },
headerTintColor: "white", headerTintColor: "white",
contentStyle: { backgroundColor: "#171717" }, contentStyle: { backgroundColor: "#171717" },

View File

@@ -11,12 +11,18 @@ 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 { 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<
@@ -117,6 +123,17 @@ 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/list.png")
: (_e) => ({ sfSymbol: "gearshape.fill" }),
}}
/>
</NativeTabs> </NativeTabs>
<MiniPlayerBar /> <MiniPlayerBar />
<MusicPlaybackEngine /> <MusicPlaybackEngine />

View File

@@ -1,6 +1,7 @@
import { import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
type MediaStream,
PlaybackOrder, PlaybackOrder,
PlaybackProgressInfo, PlaybackProgressInfo,
RepeatMode, RepeatMode,
@@ -20,6 +21,7 @@ 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 {
@@ -48,6 +50,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider"; import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { import {
getMpvAudioId, getMpvAudioId,
@@ -71,6 +74,9 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [isPipMode, setIsPipMode] = useState(false); const [isPipMode, setIsPipMode] = useState(false);
const [aspectRatio] = useState<"default" | "16:9" | "4:3" | "1:1" | "21:9">(
"default",
);
const [isZoomedToFill, setIsZoomedToFill] = useState(false); const [isZoomedToFill, setIsZoomedToFill] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
@@ -81,6 +87,12 @@ 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);
@@ -123,7 +135,6 @@ 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
@@ -146,6 +157,10 @@ 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) {
@@ -157,6 +172,17 @@ 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,
@@ -244,15 +270,18 @@ 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 () => { const fetchStreamData = async (): Promise<Stream | null> => {
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; return null;
} }
let result: Stream | null = null; let result: Stream | null = null;
@@ -270,12 +299,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; return null;
} }
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; return null;
} }
// Calculate start ticks directly from item to avoid stale closure // Calculate start ticks directly from item to avoid stale closure
@@ -294,7 +323,7 @@ export default function page() {
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: generateDeviceProfile(), deviceProfile: generateDeviceProfile(),
}); });
if (!res) return; if (!res) return null;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) { if (!sessionId || !mediaSource || !url) {
@@ -302,17 +331,22 @@ export default function page() {
t("player.error"), t("player.error"),
t("player.failed_to_get_stream_url"), t("player.failed_to_get_stream_url"),
); );
return; return null;
} }
result = { mediaSource, sessionId, url }; 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,
@@ -446,7 +480,7 @@ export default function page() {
async (data: { nativeEvent: MpvOnProgressEventPayload }) => { async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { position } = data.nativeEvent; const { position, cacheSeconds } = data.nativeEvent;
// MPV reports position in seconds, convert to ms // MPV reports position in seconds, convert to ms
const currentTime = position * 1000; const currentTime = position * 1000;
@@ -456,6 +490,12 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
// Update cache progress (current position + buffered seconds ahead)
if (cacheSeconds !== undefined && cacheSeconds > 0) {
const cacheEnd = currentTime + cacheSeconds * 1000;
cacheProgress.set(cacheEnd);
}
// Update URL immediately after seeking, or every 30 seconds during normal playback // Update URL immediately after seeking, or every 30 seconds during normal playback
const now = Date.now(); const now = Date.now();
const shouldUpdateUrl = wasJustSeeking.get(); const shouldUpdateUrl = wasJustSeeking.get();
@@ -528,7 +568,11 @@ export default function page() {
subtitleIndex, subtitleIndex,
isTranscoding, isTranscoding,
); );
const initialAudioId = getMpvAudioId(mediaSource, audioIndex); const initialAudioId = getMpvAudioId(
mediaSource,
audioIndex,
isTranscoding,
);
// Calculate start position directly here to avoid timing issues // Calculate start position directly here to avoid timing issues
const startTicks = playbackPositionFromUrl const startTicks = playbackPositionFromUrl
@@ -724,6 +768,55 @@ 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);
@@ -813,6 +906,109 @@ 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) 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,
]);
// Apply subtitle settings when video loads // Apply subtitle settings when video loads
useEffect(() => { useEffect(() => {
if (!isVideoLoaded || !videoRef.current) return; if (!isVideoLoaded || !videoRef.current) return;
@@ -951,7 +1147,36 @@ export default function page() {
</View> </View>
)} )}
</View> </View>
{isMounted === true && item && !isPipMode && ( {isMounted === true &&
item &&
!isPipMode &&
(Platform.isTV ? (
<TVControls
mediaSource={stream?.mediaSource}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
play={play}
pause={pause}
seek={seek}
audioIndex={currentAudioIndex}
subtitleIndex={currentSubtitleIndex}
onAudioIndexChange={handleAudioIndexChange}
onSubtitleIndexChange={handleSubtitleIndexChange}
previousItem={previousItem}
nextItem={nextItem}
goToPreviousItem={goToPreviousItem}
goToNextItem={goToNextItem}
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
addSubtitleFile={addSubtitleFile}
/>
) : (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -968,6 +1193,7 @@ export default function page() {
pause={pause} pause={pause}
seek={seek} seek={seek}
enableTrickplay={true} enableTrickplay={true}
aspectRatio={aspectRatio}
isZoomedToFill={isZoomedToFill} isZoomedToFill={isZoomedToFill}
onZoomToggle={handleZoomToggle} onZoomToggle={handleZoomToggle}
api={api} api={api}
@@ -980,7 +1206,7 @@ export default function page() {
playMethod={playMethod} playMethod={playMethod}
transcodeReasons={transcodeReasons} transcodeReasons={transcodeReasons}
/> />
)} ))}
</View> </View>
</VideoProvider> </VideoProvider>
</PlayerProvider> </PlayerProvider>

View File

@@ -0,0 +1,171 @@
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { 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 useRouter from "@/hooks/useAppRouter";
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
import { store } from "@/utils/store";
export default function TVOptionModal() {
const router = useRouter();
const modalState = useAtomValue(tvOptionModalAtom);
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();
};
// If no modal state, just go back (shouldn't happen in normal usage)
if (!modalState) {
return null;
}
const { title, options, cardWidth = 160, cardHeight = 75 } = modalState;
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}>{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={cardWidth}
height={cardHeight}
/>
))}
</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: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 12,
},
});

View File

@@ -0,0 +1,489 @@
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 { TVTypography } 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 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}>{t("jellyseerr.advanced")}</Text>
<Text style={styles.subtitle}>{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}>
{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: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
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: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -0,0 +1,443 @@
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 { TVTypography } 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 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}>{t("jellyseerr.select_seasons")}</Text>
<Text style={styles.subtitle}>{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}>
{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: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
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: {
fontSize: TVTypography.callout,
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: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ 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 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";
@@ -59,7 +60,7 @@ import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { userAtom } from "@/providers/JellyfinProvider"; import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store as jotaiStore, store } from "@/utils/store";
import "react-native-reanimated"; import "react-native-reanimated";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
@@ -178,7 +179,7 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<JotaiProvider> <JotaiProvider store={jotaiStore}>
<ActionSheetProvider> <ActionSheetProvider>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Layout /> <Layout />
@@ -428,6 +429,38 @@ function Layout() {
}} }}
/> />
<Stack.Screen name='+not-found' /> <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> </Stack>
<Toaster <Toaster
duration={4000} duration={4000}
@@ -443,7 +476,7 @@ function Layout() {
}} }}
closeButton closeButton
/> />
<GlobalModal /> {!Platform.isTV && <GlobalModal />}
</ThemeProvider> </ThemeProvider>
</IntroSheetProvider> </IntroSheetProvider>
</BottomSheetModalProvider> </BottomSheetModalProvider>

View File

@@ -1,659 +1,13 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Platform } from "react-native";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { Login } from "@/components/login/Login";
import { Image } from "expo-image"; import { TVLogin } from "@/components/login/TVLogin";
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 CredentialsSchema = z.object({ const LoginPage: React.FC = () => {
username: z.string().min(1, t("login.username_required")), if (Platform.isTV) {
}); 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(() => { return <Login />;
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 ( export default LoginPage;
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 {}
}, []);
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 Login;

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
assets/images/icon-tvos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,3 +1,4 @@
export * from "./api"; export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

@@ -3,6 +3,7 @@ declare global {
bytesToReadable(decimals?: number): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
} }
} }
@@ -27,4 +28,8 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds(); return this.valueOf() * (60).secondsToMilliseconds();
}; };
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {}; export {};

14
augmentations/string.ts Normal file
View File

@@ -0,0 +1,14 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this.replaceAll("_", " ").replace(
/\w\S*/g,
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
);
};
export {};

View File

@@ -58,7 +58,7 @@
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "16.5.3", "react-i18next": "16.5.3",
"react-native": "0.81.5", "react-native": "npm:react-native-tvos@0.81.5-2",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
"react-native-bottom-tabs": "1.1.0", "react-native-bottom-tabs": "1.1.0",
"react-native-circular-progress": "^1.4.1", "react-native-circular-progress": "^1.4.1",
@@ -74,7 +74,7 @@
"react-native-ios-context-menu": "^3.2.1", "react-native-ios-context-menu": "^3.2.1",
"react-native-ios-utilities": "5.2.0", "react-native-ios-utilities": "5.2.0",
"react-native-mmkv": "4.1.1", "react-native-mmkv": "4.1.1",
"react-native-nitro-modules": "0.32.1", "react-native-nitro-modules": "0.33.1",
"react-native-pager-view": "^6.9.1", "react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3", "react-native-reanimated-carousel": "4.0.3",
@@ -540,6 +540,8 @@
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="], "@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-i5L6sJ8Dae5JUWhfb5w/RgZUm3CYRFhV5/PB/xu3ASxFyHjfO0kQAqcU3ySNAOR0HfmaXK8R4OC0h07zoUWKrQ=="],
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="], "@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="], "@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
@@ -560,8 +562,6 @@
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="], "@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="],
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="], "@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="], "@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
@@ -1644,7 +1644,7 @@
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="], "react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], "react-native": ["react-native-tvos@0.81.5-2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-2", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-y/V8iFZGNXQq6b+X9VBQG19PaBpAXQHhv2vhcCMe2gEePqI2Uu8n3ClqglBn8u+Fl/GXCMcFdnJ0v0nRyxJ5TA=="],
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="], "react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
@@ -1678,7 +1678,7 @@
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="], "react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
"react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="], "react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="],
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="], "react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],

View File

@@ -1,5 +1,7 @@
import { BlurView } from "expo-blur";
import { Platform, StyleSheet, View, type ViewProps } from "react-native"; import { Platform, StyleSheet, View, type ViewProps } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view"; import { GlassEffectView } from "react-native-glass-effect-view";
import { TVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -28,7 +30,7 @@ export const Badge: React.FC<Props> = ({
</View> </View>
); );
if (Platform.OS === "ios") { if (Platform.OS === "ios" && !Platform.isTV) {
return ( return (
<View {...props} style={[styles.container, props.style]}> <View {...props} style={[styles.container, props.style]}>
<GlassEffectView style={{ borderRadius: 100 }}> <GlassEffectView style={{ borderRadius: 100 }}>
@@ -38,21 +40,70 @@ export const Badge: React.FC<Props> = ({
); );
} }
// On TV, use BlurView for consistent styling
if (Platform.isTV) {
return (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
alignSelf: "flex-start",
flexShrink: 1,
flexGrow: 0,
}}
>
<View
style={[
{
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.3)",
},
props.style,
]}
>
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
<Text
style={{
fontSize: TVTypography.callout,
color: "#E5E7EB",
}}
>
{text}
</Text>
</View>
</BlurView>
);
}
return ( return (
<View <View
{...props} {...props}
className={` style={[
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5 {
${variant === "purple" && "bg-purple-600"} borderRadius: 4,
${variant === "gray" && "bg-neutral-800"} padding: 4,
`} paddingHorizontal: 6,
flexShrink: 1,
flexGrow: 0,
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
backgroundColor: variant === "purple" ? "#9333ea" : "#262626",
},
props.style,
]}
> >
{iconLeft && <View className='mr-1'>{iconLeft}</View>} {iconLeft && <View style={{ marginRight: 4 }}>{iconLeft}</View>}
<Text <Text
className={` style={{
text-xs fontSize: 12,
${variant === "purple" && "text-white"} color: "#fff",
`} }}
> >
{text} {text}
</Text> </Text>

View File

@@ -122,7 +122,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={onPress} onPress={onPress}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
animateTo(1.08); animateTo(1.03);
}} }}
onBlur={() => { onBlur={() => {
setFocused(false); setFocused(false);
@@ -132,10 +132,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<Animated.View <Animated.View
style={{ style={{
transform: [{ scale }], transform: [{ scale }],
shadowColor: "#a855f7", shadowColor: color === "black" ? "#ffffff" : "#a855f7",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0, shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 18 : 0, shadowRadius: focused ? 10 : 0,
elevation: focused ? 12 : 0, // Android glow elevation: focused ? 12 : 0, // Android glow
}} }}
> >

View File

View File

@@ -0,0 +1,128 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 340;
type ContinueWatchingPosterProps = {
item: BaseItemDto;
useEpisodePoster?: boolean;
size?: "small" | "normal";
showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
useEpisodePoster = false,
// TV version uses fixed width, size prop kept for API compatibility
size: _size = "normal",
showPlayButton = false,
}) => {
const api = useAtomValue(apiAtom);
const url = useMemo(() => {
if (!api) {
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
}, [api, item, useEpisodePoster]);
if (!url) {
return (
<View
style={{
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
}}
/>
);
}
return (
<View
style={{
position: "relative",
width: TV_LANDSCAPE_WIDTH,
aspectRatio: 16 / 9,
borderRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit='cover'
style={{
width: "100%",
height: "100%",
}}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
<WatchedIndicator item={item} />
<ProgressBar item={item} />
</View>
);
};
export default ContinueWatchingPoster;

View File

@@ -0,0 +1,203 @@
/**
* Example Usage of Global Modal
*
* This file demonstrates how to use the global modal system from anywhere in your app.
* You can delete this file after understanding how it works.
*/
import { Ionicons } from "@expo/vector-icons";
import { TouchableOpacity, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
/**
* Example 1: Simple Content Modal
*/
export const SimpleModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6'>
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
<Text className='text-white mb-4'>
This is a simple modal with just some text content.
</Text>
<Text className='text-neutral-400'>
Swipe down or tap outside to close.
</Text>
</View>,
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-purple-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Open Simple Modal</Text>
</TouchableOpacity>
);
};
/**
* Example 2: Modal with Custom Snap Points
*/
export const CustomSnapPointsExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(
<View className='p-6' style={{ minHeight: 400 }}>
<Text className='text-2xl font-bold mb-4 text-white'>
Custom Snap Points
</Text>
<Text className='text-white mb-4'>
This modal has custom snap points (25%, 50%, 90%).
</Text>
<View className='bg-neutral-800 p-4 rounded-lg'>
<Text className='text-white'>
Try dragging the modal to different heights!
</Text>
</View>
</View>,
{
snapPoints: ["25%", "50%", "90%"],
enableDynamicSizing: false,
},
);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-blue-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Custom Snap Points</Text>
</TouchableOpacity>
);
};
/**
* Example 3: Complex Component in Modal
*/
const SettingsModalContent = () => {
const { hideModal } = useGlobalModal();
const settings = [
{
id: 1,
title: "Notifications",
icon: "notifications-outline" as const,
enabled: true,
},
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
{
id: 3,
title: "Auto-play",
icon: "play-outline" as const,
enabled: false,
},
];
return (
<View className='p-6'>
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
{settings.map((setting, index) => (
<View
key={setting.id}
className={`flex-row items-center justify-between py-4 ${
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
}`}
>
<View className='flex-row items-center gap-3'>
<Ionicons name={setting.icon} size={24} color='white' />
<Text className='text-white text-lg'>{setting.title}</Text>
</View>
<View
className={`w-12 h-7 rounded-full ${
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
}`}
>
<View
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
setting.enabled ? "translate-x-6" : "translate-x-1"
}`}
style={{ marginTop: 4 }}
/>
</View>
</View>
))}
<TouchableOpacity
onPress={hideModal}
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
>
<Text className='text-white font-semibold text-center'>Close</Text>
</TouchableOpacity>
</View>
);
};
export const ComplexModalExample = () => {
const { showModal } = useGlobalModal();
const handleOpenModal = () => {
showModal(<SettingsModalContent />);
};
return (
<TouchableOpacity
onPress={handleOpenModal}
className='bg-green-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Complex Component</Text>
</TouchableOpacity>
);
};
/**
* Example 4: Modal Triggered from Function (e.g., API response)
*/
export const useShowSuccessModal = () => {
const { showModal } = useGlobalModal();
return (message: string) => {
showModal(
<View className='p-6 items-center'>
<View className='bg-green-500 rounded-full p-4 mb-4'>
<Ionicons name='checkmark' size={48} color='white' />
</View>
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
<Text className='text-white text-center'>{message}</Text>
</View>,
);
};
};
/**
* Main Demo Component
*/
export const GlobalModalDemo = () => {
const showSuccess = useShowSuccessModal();
return (
<View className='p-6 gap-4'>
<Text className='text-2xl font-bold mb-4 text-white'>
Global Modal Examples
</Text>
<SimpleModalExample />
<CustomSnapPointsExample />
<ComplexModalExample />
<TouchableOpacity
onPress={() => showSuccess("Operation completed successfully!")}
className='bg-orange-600 px-4 py-2 rounded-lg'
>
<Text className='text-white font-semibold'>Show Success Modal</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,4 +1,5 @@
// GenreTags.tsx // GenreTags.tsx
import { BlurView } from "expo-blur";
import type React from "react"; import type React from "react";
import { import {
Platform, Platform,
@@ -9,6 +10,7 @@ import {
type ViewProps, type ViewProps,
} from "react-native"; } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view"; import { GlassEffectView } from "react-native-glass-effect-view";
import { TVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface TagProps { interface TagProps {
@@ -23,7 +25,7 @@ export const Tag: React.FC<
textStyle?: StyleProp<TextStyle>; textStyle?: StyleProp<TextStyle>;
} & ViewProps } & ViewProps
> = ({ text, textClass, textStyle, ...props }) => { > = ({ text, textClass, textStyle, ...props }) => {
if (Platform.OS === "ios") { if (Platform.OS === "ios" && !Platform.isTV) {
return ( return (
<View> <View>
<GlassEffectView style={styles.glass}> <GlassEffectView style={styles.glass}>
@@ -40,6 +42,32 @@ export const Tag: React.FC<
); );
} }
// TV-specific styling with blur background
if (Platform.isTV) {
return (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
}}
>
<View
style={{
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
{text}
</Text>
</View>
</BlurView>
);
}
return ( return (
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}> <View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}> <Text className={textClass} style={textStyle}>
@@ -66,7 +94,8 @@ export const Tags: React.FC<
return ( return (
<View <View
className={`flex flex-row flex-wrap gap-1 ${props.className}`} className={`flex flex-row flex-wrap ${props.className}`}
style={{ gap: Platform.isTV ? 12 : 4 }}
{...props} {...props}
> >
{tags.map((tag, idx) => ( {tags.map((tag, idx) => (

View File

@@ -89,16 +89,16 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</Text> </Text>
<View className='flex flex-row items-center mt-4'> <View className='flex flex-row items-center mt-4'>
<Image <Image
source={require("@/assets/icons/seerr-logo.svg")} source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{ style={{
width: 50, width: 50,
height: 50, height: 50,
}} }}
/> />
<View className='shrink ml-2'> <View className='shrink ml-2'>
<Text className='font-bold mb-1'>Seerr</Text> <Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'> <Text className='shrink text-xs'>
{t("home.intro.seerr_feature_description")} {t("home.intro.jellyseerr_feature_description")}
</Text> </Text>
</View> </View>
</View> </View>
@@ -158,12 +158,12 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
</View> </View>
<View className='shrink ml-2'> <View className='shrink ml-2'>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.intro.centralized_settings_plugin_title")} {t("home.intro.centralised_settings_plugin_title")}
</Text> </Text>
<View className='flex-row flex-wrap items-baseline'> <View className='flex-row flex-wrap items-baseline'>
<Text className='shrink text-xs'> <Text className='shrink text-xs'>
{t( {t(
"home.intro.centralized_settings_plugin_description", "home.intro.centralised_settings_plugin_description",
)}{" "} )}{" "}
</Text> </Text>
<TouchableOpacity <TouchableOpacity

View File

@@ -15,7 +15,6 @@ import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
import { MediaSourceButton } from "@/components/MediaSourceButton"; import { MediaSourceButton } from "@/components/MediaSourceButton";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
import { PlayButton } from "@/components/PlayButton"; import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus"; import { PlayedStatus } from "@/components/PlayedStatus";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
@@ -36,6 +35,9 @@ import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
const ItemContentTV = Platform.isTV
? require("./ItemContent.tv").ItemContentTV
: null;
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -45,12 +47,16 @@ export type SelectedOptions = {
}; };
interface ItemContentProps { interface ItemContentProps {
item: BaseItemDto; item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null; itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
} }
export const ItemContent: React.FC<ItemContentProps> = React.memo( // Mobile-specific implementation
({ item, itemWithSources }) => { const ItemContentMobile: React.FC<ItemContentProps> = ({
item,
itemWithSources,
}) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const isOffline = useOfflineMode(); const isOffline = useOfflineMode();
const { settings } = useSettings(); const { settings } = useSettings();
@@ -269,5 +275,15 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</ParallaxScrollView> </ParallaxScrollView>
</View> </View>
); );
}, };
);
// Memoize the mobile component
const MemoizedItemContentMobile = React.memo(ItemContentMobile);
// Exported component that renders TV or mobile version based on platform
export const ItemContent: React.FC<ItemContentProps> = (props) => {
if (Platform.isTV && ItemContentTV) {
return <ItemContentTV {...props} />;
}
return <MemoizedItemContentMobile {...props} />;
};

View File

@@ -0,0 +1,746 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import {
TVBackdrop,
TVButton,
TVCastCrewText,
TVCastSection,
TVFavoriteButton,
TVMetadataBadges,
TVOptionButton,
TVProgressBar,
TVRefreshButton,
TVSeriesNavigation,
TVTechnicalDetails,
} from "@/components/tv";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { runtimeTicksToMinutes } from "@/utils/time";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface ItemContentTVProps {
item?: BaseItemDto | null;
itemWithSources?: BaseItemDto | null;
isLoading?: boolean;
}
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
const [api] = useAtom(apiAtom);
const [_user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const queryClient = useQueryClient();
const _itemColors = useImageColorsReturn({ item });
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const {
defaultAudioIndex,
defaultBitrate,
defaultMediaSource,
defaultSubtitleIndex,
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
// Set default play options
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource ?? undefined,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
const handlePlay = () => {
if (!item || !selectedOptions) return;
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
router.push(`/player/direct-player?${queryParams.toString()}`);
};
// TV Option Modal hook for quality, audio, media source selectors
const { showOptions } = useTVOptionModal();
// TV Subtitle Modal hook
const { showSubtitleModal } = useTVSubtitleModal();
// State for first actor card ref (used for focus guide)
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
// State for last option button ref (used for upward focus guide from cast)
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
null,
);
// Get available audio tracks
const audioTracks = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Audio",
);
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available subtitle tracks
const subtitleTracks = useMemo(() => {
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
(s) => s.Type === "Subtitle",
);
return streams ?? [];
}, [selectedOptions?.mediaSource]);
// Get available media sources
const mediaSources = useMemo(() => {
return (itemWithSources ?? item)?.MediaSources ?? [];
}, [item, itemWithSources]);
// Audio options for selector
const audioOptions: TVOptionItem<number>[] = useMemo(() => {
return audioTracks.map((track) => ({
label:
track.DisplayTitle ||
`${track.Language || "Unknown"} (${track.Codec})`,
value: track.Index!,
selected: track.Index === selectedOptions?.audioIndex,
}));
}, [audioTracks, selectedOptions?.audioIndex]);
// Media source options for selector
const mediaSourceOptions: TVOptionItem<MediaSourceInfo>[] = useMemo(() => {
return mediaSources.map((source) => {
const videoStream = source.MediaStreams?.find(
(s) => s.Type === "Video",
);
const displayName =
videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`;
return {
label: displayName,
value: source,
selected: source.Id === selectedOptions?.mediaSource?.Id,
};
});
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
// Quality/bitrate options for selector
const qualityOptions: TVOptionItem<Bitrate>[] = useMemo(() => {
return BITRATES.map((bitrate) => ({
label: bitrate.key,
value: bitrate,
selected: bitrate.value === selectedOptions?.bitrate?.value,
}));
}, [selectedOptions?.bitrate?.value]);
// Handlers for option changes
const handleAudioChange = useCallback((audioIndex: number) => {
setSelectedOptions((prev) =>
prev ? { ...prev, audioIndex } : undefined,
);
}, []);
const handleSubtitleChange = useCallback((subtitleIndex: number) => {
setSelectedOptions((prev) =>
prev ? { ...prev, subtitleIndex } : undefined,
);
}, []);
const handleMediaSourceChange = useCallback(
(mediaSource: MediaSourceInfo) => {
const defaultAudio = mediaSource.MediaStreams?.find(
(s) => s.Type === "Audio" && s.IsDefault,
);
const defaultSubtitle = mediaSource.MediaStreams?.find(
(s) => s.Type === "Subtitle" && s.IsDefault,
);
setSelectedOptions((prev) =>
prev
? {
...prev,
mediaSource,
audioIndex: defaultAudio?.Index ?? prev.audioIndex,
subtitleIndex: defaultSubtitle?.Index ?? -1,
}
: undefined,
);
},
[],
);
const handleQualityChange = useCallback((bitrate: Bitrate) => {
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
}, []);
// Handle server-side subtitle download - invalidate queries to refresh tracks
const handleServerSubtitleDownloaded = useCallback(() => {
if (item?.Id) {
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
}
}, [item?.Id, queryClient]);
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
const refreshSubtitleTracks = useCallback(async (): Promise<
MediaStream[]
> => {
if (!api || !item?.Id) return [];
try {
// Fetch fresh item data with media sources
const response = await getUserLibraryApi(api).getItem({
itemId: item.Id,
});
const freshItem = response.data;
const mediaSourceId = selectedOptions?.mediaSource?.Id;
// Find the matching media source
const mediaSource = mediaSourceId
? freshItem.MediaSources?.find(
(s: MediaSourceInfo) => s.Id === mediaSourceId,
)
: freshItem.MediaSources?.[0];
// Return subtitle tracks from the fresh data
return (
mediaSource?.MediaStreams?.filter(
(s: MediaStream) => s.Type === "Subtitle",
) ?? []
);
} catch (error) {
console.error("Failed to refresh subtitle tracks:", error);
return [];
}
}, [api, item?.Id, selectedOptions?.mediaSource?.Id]);
// Get display values for buttons
const selectedAudioLabel = useMemo(() => {
const track = audioTracks.find(
(t) => t.Index === selectedOptions?.audioIndex,
);
return track?.DisplayTitle || track?.Language || t("item_card.audio");
}, [audioTracks, selectedOptions?.audioIndex, t]);
const selectedSubtitleLabel = useMemo(() => {
if (selectedOptions?.subtitleIndex === -1)
return t("item_card.subtitles.none");
const track = subtitleTracks.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
return (
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
);
}, [subtitleTracks, selectedOptions?.subtitleIndex, t]);
const selectedMediaSourceLabel = useMemo(() => {
const source = selectedOptions?.mediaSource;
if (!source) return t("item_card.video");
const videoStream = source.MediaStreams?.find((s) => s.Type === "Video");
return videoStream?.DisplayTitle || source.Name || t("item_card.video");
}, [selectedOptions?.mediaSource, t]);
const selectedQualityLabel = useMemo(() => {
return selectedOptions?.bitrate?.key || t("item_card.quality");
}, [selectedOptions?.bitrate?.key, t]);
// Format year and duration
const year = item?.ProductionYear;
const duration = item?.RunTimeTicks
? runtimeTicksToMinutes(item.RunTimeTicks)
: null;
const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0;
const remainingTime = hasProgress
? runtimeTicksToMinutes(
(item?.RunTimeTicks || 0) -
(item?.UserData?.PlaybackPositionTicks || 0),
)
: null;
// Get director
const director = item?.People?.find((p) => p.Type === "Director");
// Get cast (first 3 for text display)
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
// Get full cast for visual display (up to 10 actors)
const fullCast = useMemo(() => {
return (
item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
);
}, [item?.People]);
// Whether to show visual cast section
const showVisualCast =
(item?.Type === "Movie" ||
item?.Type === "Series" ||
item?.Type === "Episode") &&
fullCast.length > 0;
// Series/Season image URLs for episodes
const seriesImageUrl = useMemo(() => {
if (item?.Type !== "Episode" || !item.SeriesId) return null;
return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
}, [api, item?.Type, item?.SeriesId]);
const seasonImageUrl = useMemo(() => {
if (item?.Type !== "Episode") return null;
const seasonId = item.SeasonId || item.ParentId;
if (!seasonId) return null;
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined;
const hasAudioOption = audioTracks.length > 0;
const hasMediaSourceOption = mediaSources.length > 1;
if (hasSubtitleOption) return "subtitle";
if (hasAudioOption) return "audio";
if (hasMediaSourceOption) return "mediaSource";
return "quality";
}, [
subtitleTracks.length,
selectedOptions?.subtitleIndex,
audioTracks.length,
mediaSources.length,
]);
// Navigation handlers
const handleActorPress = useCallback(
(personId: string) => {
router.push(`/(auth)/persons/${personId}`);
},
[router],
);
const handleSeriesPress = useCallback(() => {
if (item?.SeriesId) {
router.push(`/(auth)/series/${item.SeriesId}`);
}
}, [router, item?.SeriesId]);
const handleSeasonPress = useCallback(() => {
if (item?.SeriesId && item?.ParentIndexNumber) {
router.push(
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
);
}
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
if (!item || !selectedOptions) return null;
return (
<View
style={{
flex: 1,
backgroundColor: "#000000",
}}
>
{/* Full-screen backdrop */}
<TVBackdrop item={item} />
{/* Main content area */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 140,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
>
{/* Top section - Logo/Title + Metadata */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
<ItemImage
variant='Primary'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
</View>
{/* Right side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
<Image
source={{ uri: logoUrl }}
style={{
height: 150,
width: "80%",
marginBottom: 24,
}}
contentFit='contain'
contentPosition='left'
/>
) : (
<Text
style={{
fontSize: TVTypography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 20,
}}
numberOfLines={2}
>
{item.Name}
</Text>
)}
{/* Episode info for TV shows */}
{item.Type === "Episode" && (
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: TVTypography.title,
color: "#FFFFFF",
fontWeight: "600",
}}
>
{item.SeriesName}
</Text>
<Text
style={{
fontSize: TVTypography.body,
color: "white",
marginTop: 6,
}}
>
S{item.ParentIndexNumber} E{item.IndexNumber} · {item.Name}
</Text>
</View>
)}
{/* Metadata badges row */}
<TVMetadataBadges
year={year}
duration={duration}
officialRating={item.OfficialRating}
communityRating={item.CommunityRating}
/>
{/* Genres */}
{item.Genres && item.Genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={item.Genres} />
</View>
)}
{/* Overview */}
{item.Overview && (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text
style={{
fontSize: TVTypography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
numberOfLines={4}
>
{item.Overview}
</Text>
</View>
</BlurView>
)}
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 32,
}}
>
<TVButton
onPress={handlePlay}
hasTVPreferredFocus
variant='primary'
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#000000",
}}
>
{hasProgress
? `${remainingTime} ${t("item_card.left")}`
: t("common.play")}
</Text>
</TVButton>
<TVFavoriteButton item={item} />
<TVRefreshButton itemId={item.Id} />
</View>
{/* Playback options */}
<View
style={{
flexDirection: "column",
alignItems: "flex-start",
gap: 10,
marginBottom: 24,
}}
>
{/* Quality selector */}
<TVOptionButton
ref={
lastOptionButton === "quality"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.quality")}
value={selectedQualityLabel}
onPress={() =>
showOptions({
title: t("item_card.quality"),
options: qualityOptions,
onSelect: handleQualityChange,
})
}
/>
{/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && (
<TVOptionButton
ref={
lastOptionButton === "mediaSource"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.video")}
value={selectedMediaSourceLabel}
onPress={() =>
showOptions({
title: t("item_card.video"),
options: mediaSourceOptions,
onSelect: handleMediaSourceChange,
})
}
/>
)}
{/* Audio selector */}
{audioTracks.length > 0 && (
<TVOptionButton
ref={
lastOptionButton === "audio"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.audio")}
value={selectedAudioLabel}
onPress={() =>
showOptions({
title: t("item_card.audio"),
options: audioOptions,
onSelect: handleAudioChange,
})
}
/>
)}
{/* Subtitle selector */}
{(subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
ref={
lastOptionButton === "subtitle"
? setLastOptionButtonRef
: undefined
}
label={t("item_card.subtitles.label")}
value={selectedSubtitleLabel}
onPress={() =>
showSubtitleModal({
item,
mediaSourceId: selectedOptions?.mediaSource?.Id,
subtitleTracks,
currentSubtitleIndex:
selectedOptions?.subtitleIndex ?? -1,
onSubtitleIndexChange: handleSubtitleChange,
onServerSubtitleDownloaded:
handleServerSubtitleDownloaded,
refreshSubtitleTracks,
})
}
/>
)}
</View>
{/* Focus guide to direct navigation from options to cast list */}
{fullCast.length > 0 && firstActorCardRef && (
<TVFocusGuideView
destinations={[firstActorCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<TVProgressBar
progress={
(item.UserData?.PlaybackPositionTicks || 0) /
item.RunTimeTicks
}
fillColor='#FFFFFF'
/>
)}
</View>
</View>
{/* Additional info section */}
<View style={{ marginTop: 40 }}>
{/* Cast & Crew (text version) */}
<TVCastCrewText
director={director}
cast={cast}
hideCast={showVisualCast}
/>
{/* Technical details */}
{selectedOptions.mediaSource?.MediaStreams &&
selectedOptions.mediaSource.MediaStreams.length > 0 && (
<TVTechnicalDetails
mediaStreams={selectedOptions.mediaSource.MediaStreams}
/>
)}
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
upwardFocusDestination={lastOptionButtonRef}
/>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
</View>
</ScrollView>
</View>
);
},
);
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
export const ItemContent = ItemContentTV;

View File

@@ -0,0 +1,160 @@
import React from "react";
import { Dimensions, View } from "react-native";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export const ItemContentSkeletonTV: React.FC = () => {
return (
<View
style={{
flex: 1,
flexDirection: "row",
paddingTop: 180,
paddingHorizontal: 160,
}}
>
{/* Left side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
{/* Right side - Content placeholders */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo/Title placeholder */}
<View
style={{
height: 80,
width: "60%",
backgroundColor: "#1a1a1a",
borderRadius: 8,
marginBottom: 24,
}}
/>
{/* Metadata badges row */}
<View
style={{
flexDirection: "row",
gap: 12,
marginBottom: 20,
}}
>
<View
style={{
height: 24,
width: 60,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
<View
style={{
height: 24,
width: 80,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
<View
style={{
height: 24,
width: 50,
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
</View>
{/* Genres placeholder */}
<View
style={{
flexDirection: "row",
gap: 8,
marginBottom: 24,
}}
>
<View
style={{
height: 28,
width: 80,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
<View
style={{
height: 28,
width: 100,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
<View
style={{
height: 28,
width: 70,
backgroundColor: "#1a1a1a",
borderRadius: 14,
}}
/>
</View>
{/* Overview placeholder */}
<View
style={{
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
height: 18,
width: "100%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
marginBottom: 8,
}}
/>
<View
style={{
height: 18,
width: "90%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
marginBottom: 8,
}}
/>
<View
style={{
height: 18,
width: "75%",
backgroundColor: "#1a1a1a",
borderRadius: 4,
}}
/>
</View>
{/* Play button placeholder */}
<View
style={{
height: 56,
width: 180,
backgroundColor: "#1a1a1a",
borderRadius: 12,
}}
/>
</View>
</View>
);
};

View File

@@ -77,7 +77,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
<View> <View>
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")} {t("item_card.subtitles.label")}
</Text> </Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={

View File

@@ -142,7 +142,7 @@ export const MediaSourceButton: React.FC<Props> = ({
})); }));
groups.push({ groups.push({
title: t("item_card.subtitles"), title: t("item_card.subtitles.label"),
options: [noneOption, ...subtitleOptions], options: [noneOption, ...subtitleOptions],
}); });
} }

View File

@@ -11,11 +11,9 @@ import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;
actorName?: string | null; actorName?: string | null;

View File

@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useMemo } from "react"; import { useMemo } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { import type {
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
); );
}; };
export const SeerrRatings: React.FC<{ export const JellyserrRatings: React.FC<{
result: MovieResult | TvResult | TvDetails | MovieDetails; result: MovieResult | TvResult | TvDetails | MovieDetails;
}> = ({ result }) => { }> = ({ result }) => {
const { seerrApi, getMediaType } = useSeerr(); const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]); const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["seerr", result.id, mediaType, "ratings"], queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? seerrApi?.movieRatings(result.id) ? jellyseerrApi?.movieRatings(result.id)
: seerrApi?.tvRatings(result.id); : jellyseerrApi?.tvRatings(result.id);
}, },
staleTime: (5).minutesToMilliseconds(), staleTime: (5).minutesToMilliseconds(),
retry: false, retry: false,
enabled: !!seerrApi, enabled: !!jellyseerrApi,
}); });
return ( return (

View File

@@ -6,11 +6,8 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { HorizontalScroll } from "./common/HorizontalScroll"; import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";

View File

@@ -76,7 +76,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const trigger = ( const trigger = (
<View className='flex flex-col' {...props}> <View className='flex flex-col' {...props}>
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'> <Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")} {t("item_card.subtitles.label")}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between' className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'
@@ -97,7 +97,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<PlatformDropdown <PlatformDropdown
groups={optionGroups} groups={optionGroups}
trigger={trigger} trigger={trigger}
title={t("item_card.subtitles")} title={t("item_card.subtitles.label")}
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
onOptionSelect={handleOptionSelect} onOptionSelect={handleOptionSelect}

View File

@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
const streams = useMemo( const streams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === streamType), () => source?.MediaStreams?.filter((x) => x.Type === streamType),
[source, streamType], [source],
); );
const selectedSteam = useMemo( const selectedSteam = useMemo(

View File

@@ -1,8 +1,37 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import type React from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (Platform.isTV) {
// TV: Show white checkmark when watched
if (
item.UserData?.Played &&
(item.Type === "Movie" || item.Type === "Episode")
) {
return (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
);
}
return null;
}
// Mobile: Show purple triangle for unwatched
return ( return (
<> <>
{item.UserData?.Played === false && {item.UserData?.Played === false &&

View File

@@ -1,50 +1,132 @@
import React, { useState } from "react"; import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRef, useState } from "react";
import { import {
Animated,
Easing,
Platform, Platform,
Pressable,
TextInput, TextInput,
type TextInputProps, type TextInputProps,
TouchableOpacity, View,
} from "react-native"; } from "react-native";
interface InputProps extends TextInputProps { interface InputProps extends TextInputProps {
extraClassName?: string; // new prop for additional classes extraClassName?: string;
} }
export function Input(props: InputProps) { export function Input(props: InputProps) {
const { style, extraClassName = "", ...otherProps } = props; const { style, extraClassName = "", ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
return Platform.isTV ? ( const animateFocus = (focused: boolean) => {
<TouchableOpacity Animated.timing(scale, {
onPress={() => inputRef?.current?.focus?.()} toValue: focused ? 1.02 : 1,
activeOpacity={1} duration: 150,
> easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
const handleFocus = () => {
setIsFocused(true);
animateFocus(true);
};
const handleBlur = () => {
setIsFocused(false);
animateFocus(false);
};
if (Platform.isTV) {
const containerStyle = {
height: 48,
borderRadius: 50,
borderWidth: isFocused ? 1.5 : 1,
borderColor: isFocused
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.1)",
overflow: "hidden" as const,
flexDirection: "row" as const,
alignItems: "center" as const,
paddingLeft: 16,
};
const inputElement = (
<>
<Ionicons
name='search'
size={20}
color={isFocused ? "#999" : "#666"}
style={{ marginRight: 12 }}
/>
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className={`
w-full text-lg px-5 py-4 rounded-2xl
${isFocused ? "bg-neutral-700 border-2 border-white" : "bg-neutral-900 border-2 border-transparent"}
text-white ${extraClassName}
`}
allowFontScaling={false} allowFontScaling={false}
placeholderTextColor='#666'
style={[ style={[
style,
{ {
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88", flex: 1,
height: 48,
fontSize: 18,
fontWeight: "400",
color: "#FFFFFF",
backgroundColor: "transparent",
}, },
style,
]} ]}
placeholderTextColor={"#ffffffff"} onFocus={handleFocus}
clearButtonMode='while-editing' onBlur={handleBlur}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...otherProps} {...otherProps}
/> />
</TouchableOpacity> </>
);
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleBlur}
>
<Animated.View
style={{
transform: [{ scale }],
}}
>
{Platform.OS === "ios" ? (
<BlurView
intensity={isFocused ? 90 : 80}
tint='dark'
style={containerStyle}
>
{inputElement}
</BlurView>
) : ( ) : (
<View
style={[
containerStyle,
{
backgroundColor: isFocused
? "rgba(255, 255, 255, 0.12)"
: "rgba(255, 255, 255, 0.08)",
},
]}
>
{inputElement}
</View>
)}
</Animated.View>
</Pressable>
);
}
// Mobile version unchanged
return (
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className='p-4 rounded-xl bg-neutral-900' className={`p-4 rounded-xl bg-neutral-900 ${extraClassName}`}
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"} placeholderTextColor={"#9CA3AF"}

View File

@@ -21,7 +21,7 @@ interface Props extends TouchableOpacityProps {
mediaType: MediaType; mediaType: MediaType;
} }
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
result, result,
mediaTitle, mediaTitle,
releaseYear, releaseYear,
@@ -42,24 +42,18 @@ export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
onPress={() => { onPress={() => {
if (!result) return; if (!result) return;
// Build URL with query params - avoids Expo Router's strict type checking router.push({
const params = new URLSearchParams({ pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
...Object.fromEntries( // @ts-expect-error
Object.entries(result).map(([key, value]) => [ params: {
key, ...result,
String(value ?? ""),
]),
),
mediaTitle, mediaTitle,
releaseYear: releaseYear.toString(), releaseYear,
canRequest: canRequest.toString(), canRequest: canRequest.toString(),
posterSrc, posterSrc,
mediaType: mediaType.toString(), mediaType,
},
}); });
router.push(
`/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/seerr/page?${params.toString()}`,
);
}} }}
{...props} {...props}
> >

View File

@@ -0,0 +1,20 @@
import { Image } from "expo-image";
import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url)
return (
<View className='p-4 rounded-xl overflow-hidden '>
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
</View>
);
return (
<View className='p-4 rounded-xl overflow-hidden '>
<Image
source={{ uri: url }}
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/>
</View>
);
};

View File

@@ -1,6 +1,6 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
interface ProgressBarProps { interface ProgressBarProps {
item: BaseItemDto; item: BaseItemDto;
@@ -39,8 +39,9 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
<View <View
style={{ style={{
width: `${progress}%`, width: `${progress}%`,
backgroundColor: Platform.isTV ? "#ffffff" : undefined,
}} }}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"} className={`absolute bottom-0 left-0 h-1 w-full ${Platform.isTV ? "" : "bg-purple-600"}`}
/> />
</> </>
); );

View File

@@ -0,0 +1,28 @@
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className='flex flex-col'
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className='w-full bg-neutral-800 mb-2 rounded-lg'
/>
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
</View>
);
};

View File

@@ -0,0 +1,232 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { TVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
const SECTION_GAP = 10;
type FavoriteTypes =
| "Series"
| "Movie"
| "Episode"
| "Video"
| "BoxSet"
| "Playlist";
type EmptyState = Record<FavoriteTypes, boolean>;
export const Favorites = () => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const pageSize = 20;
const [emptyState, setEmptyState] = useState<EmptyState>({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
const fetchFavoritesByType = useCallback(
async (
itemType: BaseItemKind,
startIndex: number = 0,
limit: number = 20,
) => {
const response = await getItemsApi(api as Api).getItems({
userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"],
sortOrder: ["Ascending"],
filters: ["IsFavorite"],
recursive: true,
fields: ["PrimaryImageAspectRatio"],
collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false,
startIndex: startIndex,
limit: limit,
includeItemTypes: [itemType],
});
const items = response.data.Items || [];
if (startIndex === 0) {
setEmptyState((prev) => ({
...prev,
[itemType as FavoriteTypes]: items.length === 0,
}));
}
return items;
},
[api, user],
);
useEffect(() => {
setEmptyState({
Series: false,
Movie: false,
Episode: false,
Video: false,
BoxSet: false,
Playlist: false,
});
}, [api, user]);
const areAllEmpty = () => {
const loadedCategories = Object.values(emptyState);
return (
loadedCategories.length > 0 &&
loadedCategories.every((isEmpty) => isEmpty)
);
};
const fetchFavoriteSeries = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Series", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteMovies = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Movie", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteEpisodes = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Episode", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteVideos = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Video", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoriteBoxsets = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("BoxSet", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
const fetchFavoritePlaylists = useCallback(
({ pageParam }: { pageParam: number }) =>
fetchFavoritesByType("Playlist", pageParam, pageSize),
[fetchFavoritesByType, pageSize],
);
if (areAllEmpty()) {
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Image
style={{
width: 64,
height: 64,
marginBottom: 16,
tintColor: Colors.primary,
}}
contentFit='contain'
source={heart}
/>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("favorites.noDataTitle")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
color: "#FFFFFF",
}}
>
{t("favorites.noData")}
</Text>
</View>
);
}
return (
<ScrollView
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]}
title={t("favorites.series")}
hideIfEmpty
pageSize={pageSize}
isFirstSection
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")}
hideIfEmpty
orientation='vertical'
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")}
hideIfEmpty
pageSize={pageSize}
/>
<InfiniteScrollingCollectionList
queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")}
hideIfEmpty
pageSize={pageSize}
/>
</View>
</ScrollView>
);
};

View File

@@ -44,6 +44,9 @@ import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
// Conditionally load TV version
const HomeTV = Platform.isTV ? require("./Home.tv").Home : null;
type InfiniteScrollingCollectionListSection = { type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList"; type: "InfiniteScrollingCollectionList";
title?: string; title?: string;
@@ -64,7 +67,7 @@ type MediaListSectionType = {
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType; type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
export const Home = () => { const HomeMobile = () => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -687,3 +690,11 @@ export const Home = () => {
</ScrollView> </ScrollView>
); );
}; };
// Exported component that renders TV or mobile version based on platform
export const Home = () => {
if (Platform.isTV && HomeTV) {
return <HomeTV />;
}
return <HomeMobile />;
};

755
components/home/Home.tv.tsx Normal file
View File

@@ -0,0 +1,755 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
import { Loader } from "@/components/Loader";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Reduced gap since sections have internal padding for scale animations
const SECTION_GAP = 10;
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[], any, number>;
orientation?: "horizontal" | "vertical";
pageSize?: number;
priority?: 1 | 2;
parentId?: string;
};
type Section = InfiniteScrollingCollectionListSection;
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
const BACKDROP_DEBOUNCE_MS = 300;
export const Home = () => {
const _router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const insets = useSafeAreaInsets();
const { settings } = useSettings();
const scrollRef = useRef<ScrollView>(null);
const {
isConnected,
serverConnected,
loading: retryLoading,
retryCheck,
} = useNetworkStatus();
const _invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Handle item focus with debounce
const handleItemFocus = useCallback((item: BaseItemDto) => {
// Clear any pending debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer to update focused item after debounce delay
debounceTimerRef.current = setTimeout(() => {
setFocusedItem(item);
}, BACKDROP_DEBOUNCE_MS);
}, []);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Get backdrop URL from focused item (only if setting is enabled)
const backdropUrl = useMemo(() => {
if (!settings.showHomeBackdrop || !focusedItem) return null;
return getBackdropUrl({
api,
item: focusedItem,
quality: 90,
width: 1920,
});
}, [api, focusedItem, settings.showHomeBackdrop]);
// Crossfade animation for backdrop transitions
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
const [layer0Url, setLayer0Url] = useState<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(null);
const layer0Opacity = useRef(new Animated.Value(0)).current;
const layer1Opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (!backdropUrl) return;
let isCancelled = false;
const performCrossfade = async () => {
// Prefetch the image before starting the crossfade
try {
await Image.prefetch(backdropUrl);
} catch {
// Continue even if prefetch fails
}
if (isCancelled) return;
// Determine which layer to fade in
const incomingLayer = activeLayer === 0 ? 1 : 0;
const incomingOpacity =
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
const outgoingOpacity =
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
// Set the new URL on the incoming layer
if (incomingLayer === 0) {
setLayer0Url(backdropUrl);
} else {
setLayer1Url(backdropUrl);
}
// Small delay to ensure image component has the new URL
await new Promise((resolve) => setTimeout(resolve, 50));
if (isCancelled) return;
// Crossfade: fade in the incoming layer, fade out the outgoing
Animated.parallel([
Animated.timing(incomingOpacity, {
toValue: 1,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(outgoingOpacity, {
toValue: 0,
duration: 500,
easing: Easing.inOut(Easing.quad),
useNativeDriver: true,
}),
]).start(() => {
if (!isCancelled) {
setActiveLayer(incomingLayer);
}
});
};
performCrossfade();
return () => {
isCancelled = true;
};
}, [backdropUrl]);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries],
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType),
) || []
);
}, [userViews]);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined,
pageSize = 10,
): InfiniteScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async ({ pageParam = 0 }) => {
if (!api) return [];
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 10,
fields: ["PrimaryImageAspectRatio"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || [];
return allData.slice(pageParam, pageParam + pageSize);
},
type: "InfiniteScrollingCollectionList",
pageSize,
parentId,
}),
[api, user?.Id],
);
const defaultSections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey: string[] = [
"home",
`recentlyAddedIn${c.CollectionType}`,
user.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id,
10,
);
});
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
return items.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
};
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
const seen = new Set<string>();
return items.filter((item) => {
if (!item.Id || seen.has(item.Id)) return false;
seen.add(item.Id);
return true;
});
};
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
? [
{
title: t("home.continue_and_next_up"),
queryKey: ["home", "continueAndNextUp"],
queryFn: async ({ pageParam = 0 }) => {
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
startIndex: 0,
limit: 20,
}),
getTvShowsApi(api).getNextUp({
userId: user?.Id,
startIndex: 0,
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
}),
]);
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
const combined = [...resumeItems, ...nextUpItems];
const sorted = sortByRecentActivity(combined);
const deduplicated = deduplicateById(sorted);
return deduplicated.slice(pageParam, pageParam + 10);
},
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
]
: [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async ({ pageParam = 0 }) =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
startIndex: pageParam,
limit: 10,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async ({ pageParam = 0 }) =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "InfiniteScrollingCollectionList",
orientation: "horizontal",
pageSize: 10,
priority: 1,
},
];
const ss: Section[] = [
...firstSections,
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
...(!settings?.streamyStatsMovieRecommendations
? [
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
startIndex: pageParam,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "InfiniteScrollingCollectionList" as const,
orientation: "vertical" as const,
pageSize: 10,
priority: 2 as const,
},
]
: []),
];
return ss;
}, [
api,
user?.Id,
collections,
t,
createCollectionConfig,
settings?.streamyStatsMovieRecommendations,
settings.mergeNextUpAndContinueWatching,
]);
const customSections = useMemo(() => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
settings.home.sections.forEach((section, index) => {
const id = section.title || `section-${index}`;
const pageSize = 10;
ss.push({
title: t(`${id}`),
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async ({ pageParam = 0 }) => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
startIndex: pageParam,
limit: section.items?.limit || pageSize,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
}
if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
startIndex: pageParam,
limit: section.nextUp?.limit || pageSize,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.nextUp?.enableResumable,
enableRewatching: section.nextUp?.enableRewatching,
});
return response.data.Items || [];
}
if (section.latest) {
const allData =
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
includeItemTypes: section.latest?.includeItemTypes,
limit: section.latest?.limit || 10,
isPlayed: section.latest?.isPlayed,
groupItems: section.latest?.groupItems,
})
).data || [];
return allData.slice(pageParam, pageParam + pageSize);
}
if (section.custom) {
const response = await api.get<BaseItemDtoQueryResult>(
section.custom.endpoint,
{
params: {
...(section.custom.query || {}),
userId: user?.Id,
startIndex: pageParam,
limit: pageSize,
},
headers: section.custom.headers || {},
},
);
return response.data.Items || [];
}
return [];
},
type: "InfiniteScrollingCollectionList",
orientation: section?.orientation || "vertical",
pageSize,
priority: index < 2 ? 1 : 2,
});
});
return ss;
}, [api, user?.Id, settings?.home?.sections, t]);
const sections = settings?.home?.sections ? customSections : defaultSections;
const highPrioritySectionKeys = useMemo(() => {
return sections
.filter((s) => s.priority === 1)
.map((s) => s.queryKey.join("-"));
}, [sections]);
const allHighPriorityLoaded = useMemo(() => {
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
}, [highPrioritySectionKeys, loadedSections]);
const markSectionLoaded = useCallback(
(queryKey: (string | undefined | null)[]) => {
const key = queryKey.join("-");
setLoadedSections((prev) => new Set(prev).add(key));
},
[],
);
if (!isConnected || serverConnected !== true) {
let title = "";
let subtitle = "";
if (!isConnected) {
title = t("home.no_internet");
subtitle = t("home.no_internet_message");
} else if (serverConnected === null) {
title = t("home.checking_server_connection");
subtitle = t("home.checking_server_connection_message");
} else if (!serverConnected) {
title = t("home.server_unreachable");
subtitle = t("home.server_unreachable_message");
}
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: HORIZONTAL_PADDING,
}}
>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{title}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
color: "#FFFFFF",
}}
>
{subtitle}
</Text>
<View style={{ marginTop: 24 }}>
<Button
color='black'
onPress={retryCheck}
justify='center'
iconRight={
retryLoading ? null : (
<Ionicons name='refresh' size={24} color='white' />
)
}
>
{retryLoading ? (
<ActivityIndicator size='small' color='white' />
) : (
t("home.retry")
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View
style={{
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
}}
>
{t("home.oops")}
</Text>
<Text
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
color: "#FFFFFF",
}}
>
{t("home.error_message")}
</Text>
</View>
);
if (l1)
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Loader />
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists;
const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent ? (
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
</View>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
const isFirstSection = index === 0;
return (
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
parentId={section.parentId}
/>
{streamystatsSections}
</View>
);
}
return null;
})}
</View>
</ScrollView>
</View>
);
};

View File

@@ -569,31 +569,29 @@ export const HomeWithCarousel = () => {
settings.streamyStatsSeriesRecommendations || settings.streamyStatsSeriesRecommendations ||
settings.streamyStatsPromotedWatchlists; settings.streamyStatsPromotedWatchlists;
const streamystatsSections = const streamystatsSections =
index === streamystatsIndex && hasStreamystatsContent index === streamystatsIndex && hasStreamystatsContent ? (
? [ <>
settings.streamyStatsMovieRecommendations && ( {settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations <StreamystatsRecommendations
key='movie-recommendations'
title={t( title={t(
"home.settings.plugins.streamystats.recommended_movies", "home.settings.plugins.streamystats.recommended_movies",
)} )}
type='Movie' type='Movie'
/> />
), )}
settings.streamyStatsSeriesRecommendations && ( {settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations <StreamystatsRecommendations
key='series-recommendations'
title={t( title={t(
"home.settings.plugins.streamystats.recommended_series", "home.settings.plugins.streamystats.recommended_series",
)} )}
type='Series' type='Series'
/> />
), )}
settings.streamyStatsPromotedWatchlists && ( {settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists key='promoted-watchlists' /> <StreamystatsPromotedWatchlists />
), )}
].filter(Boolean) </>
: null; ) : null;
if (section.type === "InfiniteScrollingCollectionList") { if (section.type === "InfiniteScrollingCollectionList") {
return ( return (

View File

@@ -0,0 +1,488 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
type QueryFunction,
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
View,
type ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster, {
TV_LANDSCAPE_WIDTH,
} from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 16;
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
hideIfEmpty?: boolean;
pageSize?: number;
onPressSeeAll?: () => void;
enabled?: boolean;
onLoaded?: () => void;
isFirstSection?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
parentId?: string;
}
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
{item.SeriesName}
</Text>
</>
) : (
<>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</>
)}
</View>
);
};
// TV-specific "See All" card for end of lists
const TVSeeAllCard: React.FC<{
onPress: () => void;
orientation: "horizontal" | "vertical";
disabled?: boolean;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
return (
<View style={{ width }}>
<TVFocusablePoster
onPress={onPress}
disabled={disabled}
onFocus={onFocus}
onBlur={onBlur}
>
<View
style={{
width,
aspectRatio,
borderRadius: 24,
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
}}
>
<Ionicons
name='arrow-forward'
size={32}
color='white'
style={{ marginBottom: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
color: "#FFFFFF",
fontWeight: "600",
}}
>
{t("common.seeAll", { defaultValue: "See all" })}
</Text>
</View>
</TVFocusablePoster>
</View>
);
};
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 10,
enabled = true,
onLoaded,
isFirstSection = false,
onItemFocus,
parentId,
...props
}) => {
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
// Track focus within section for item focus/blur callbacks
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [_focusedCount, setFocusedCount] = useState(0);
const handleItemFocus = useCallback(
(item: BaseItemDto) => {
setFocusedCount((c) => c + 1);
onItemFocus?.(item);
},
[onItemFocus],
);
const handleItemBlur = useCallback(() => {
setFocusedCount((c) => Math.max(0, c - 1));
}, []);
// Focus handler for See All card (doesn't need item parameter)
const handleSeeAllFocus = useCallback(() => {
setFocusedCount((c) => c + 1);
}, []);
const {
data,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isSuccess,
} = useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < effectivePageSize) {
return undefined;
}
return allPages.reduce((acc, page) => acc + page.length, 0);
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
});
useEffect(() => {
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
hasCalledOnLoaded.current = true;
onLoaded();
}
}, [isSuccess, onLoaded]);
const { t } = useTranslation();
const allItems = useMemo(() => {
const items = data?.pages.flat() ?? [];
const seen = new Set<string>();
const deduped: BaseItemDto[] = [];
for (const item of items) {
const id = item.Id;
if (!id) continue;
if (seen.has(id)) continue;
seen.add(id);
deduped.push(item);
}
return deduped;
}, [data]);
const itemWidth =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const handleEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleSeeAllPress = useCallback(() => {
if (!parentId) return;
router.push({
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
params: {
libraryId: parentId,
sortBy: SortByOption.DateCreated,
sortOrder: SortOrderOption.Descending,
},
} as any);
}, [router, parentId]);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: itemWidth + ITEM_GAP,
offset: (itemWidth + ITEM_GAP) * index,
index,
}),
[itemWidth],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = isFirstSection && index === 0;
const isHorizontal = orientation === "horizontal";
const renderPoster = () => {
if (item.Type === "Episode" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Episode" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Movie" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Movie" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Series" && !isHorizontal) {
return <SeriesPoster item={item} />;
}
if (item.Type === "Series" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Program") {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "BoxSet" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "BoxSet" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Playlist" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Playlist" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
if (item.Type === "Video" && !isHorizontal) {
return <MoviePoster item={item} />;
}
if (item.Type === "Video" && isHorizontal) {
return <ContinueWatchingPoster item={item} />;
}
// Default fallback
return isHorizontal ? (
<ContinueWatchingPoster item={item} />
) : (
<MoviePoster item={item} />
);
};
return (
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[
orientation,
isFirstSection,
itemWidth,
handleItemPress,
handleItemFocus,
handleItemBlur,
],
);
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
{/* Section Header */}
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
{isLoading === false && allItems.length === 0 && (
<Text
style={{
color: "#737373",
fontSize: TVTypography.callout,
marginLeft: SCALE_PADDING,
}}
>
{t("home.no_items")}
</Text>
)}
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: itemWidth }}>
<View
style={{
backgroundColor: "#262626",
width: itemWidth,
aspectRatio: orientation === "horizontal" ? 16 / 9 : 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
<View
style={{
borderRadius: 6,
overflow: "hidden",
marginBottom: 4,
alignSelf: "flex-start",
}}
>
<Text
style={{
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: TVTypography.callout,
}}
numberOfLines={1}
>
Placeholder text here
</Text>
</View>
</View>
))}
</View>
) : (
<FlatList
ref={flatListRef}
horizontal
data={allItems}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
onEndReached={handleEndReached}
onEndReachedThreshold={0.5}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
ListFooterComponent={
<View
style={{
flexDirection: "row",
alignItems: "center",
}}
>
{isFetchingNextPage && (
<View
style={{
marginLeft: itemWidth / 2,
marginRight: ITEM_GAP,
justifyContent: "center",
height: orientation === "horizontal" ? 191 : 315,
}}
>
<ActivityIndicator size='small' color='white' />
</View>
)}
{parentId && allItems.length > 0 && (
<TVSeeAllCard
onPress={handleSeeAllPress}
orientation={orientation}
disabled={disabled}
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
/>
)}
</View>
}
/>
)}
</View>
);
};

View File

@@ -247,14 +247,15 @@ export const StreamystatsPromotedWatchlists: React.FC<
} }
return ( return (
<View {...props}> <>
{watchlists?.map((watchlist) => ( {watchlists?.map((watchlist) => (
<WatchlistSection <WatchlistSection
key={watchlist.id} key={watchlist.id}
watchlist={watchlist} watchlist={watchlist}
jellyfinServerId={jellyfinServerId!} jellyfinServerId={jellyfinServerId!}
{...props}
/> />
))} ))}
</View> </>
); );
}; };

View File

@@ -0,0 +1,342 @@
import type {
BaseItemDto,
PublicSystemInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
interface WatchlistSectionProps extends ViewProps {
watchlist: StreamystatsWatchlist;
jellyfinServerId: string;
onItemFocus?: (item: BaseItemDto) => void;
}
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
watchlist,
jellyfinServerId,
onItemFocus,
...props
}) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const { data: items, isLoading } = useQuery({
queryKey: [
"streamystats",
"watchlist",
watchlist.id,
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<BaseItemDto[]> => {
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
return [];
}
const streamystatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
watchlistId: watchlist.id,
jellyfinServerId,
});
const itemIds = watchlistDetail.data?.items;
if (!itemIds?.length) {
return [];
}
const response = await getItemsApi(api).getItems({
userId: user.Id,
ids: itemIds,
fields: ["PrimaryImageAspectRatio", "Genres"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
enabled:
Boolean(settings?.streamyStatsServerUrl) &&
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[handleItemPress, onItemFocus],
);
if (!isLoading && (!items || items.length === 0)) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{watchlist.name}
</Text>
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
</View>
))}
</View>
) : (
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
</View>
);
};
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
enabled?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
export const StreamystatsPromotedWatchlists: React.FC<
StreamystatsPromotedWatchlistsProps
> = ({ enabled = true, onItemFocus, ...props }) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
}, [settings?.streamyStatsServerUrl]);
const { data: serverInfo } = useQuery({
queryKey: ["jellyfin", "serverInfo"],
queryFn: async (): Promise<PublicSystemInfo | null> => {
if (!api) return null;
const response = await getSystemApi(api).getPublicSystemInfo();
return response.data;
},
enabled: enabled && Boolean(api) && streamyStatsEnabled,
staleTime: 60 * 60 * 1000,
});
const jellyfinServerId = serverInfo?.Id;
const {
data: watchlists,
isLoading,
isError,
} = useQuery({
queryKey: [
"streamystats",
"promotedWatchlists",
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
if (
!settings?.streamyStatsServerUrl ||
!api?.accessToken ||
!jellyfinServerId
) {
return [];
}
const streamystatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const response = await streamystatsApi.getPromotedWatchlists({
jellyfinServerId,
includePreview: false,
});
return response.data || [];
},
enabled:
enabled &&
streamyStatsEnabled &&
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
if (!streamyStatsEnabled) return null;
if (isError) return null;
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
if (isLoading) {
return (
<View style={{ overflow: "visible" }} {...props}>
<View
style={{
height: 16,
width: 128,
backgroundColor: "#262626",
borderRadius: 4,
marginLeft: SCALE_PADDING,
marginBottom: 16,
}}
/>
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
</View>
))}
</View>
</View>
);
}
return (
<>
{watchlists?.map((watchlist) => (
<WatchlistSection
key={watchlist.id}
watchlist={watchlist}
jellyfinServerId={jellyfinServerId!}
onItemFocus={onItemFocus}
{...props}
/>
))}
</>
);
};

View File

@@ -0,0 +1,275 @@
import type {
BaseItemDto,
PublicSystemInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useMemo } from "react";
import { FlatList, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
limit?: number;
enabled?: boolean;
onItemFocus?: (item: BaseItemDto) => void;
}
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
};
export const StreamystatsRecommendations: React.FC<Props> = ({
title,
type,
limit = 20,
enabled = true,
onItemFocus,
...props
}) => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
const streamyStatsEnabled = useMemo(() => {
return Boolean(settings?.streamyStatsServerUrl);
}, [settings?.streamyStatsServerUrl]);
const { data: serverInfo } = useQuery({
queryKey: ["jellyfin", "serverInfo"],
queryFn: async (): Promise<PublicSystemInfo | null> => {
if (!api) return null;
const response = await getSystemApi(api).getPublicSystemInfo();
return response.data;
},
enabled: enabled && Boolean(api) && streamyStatsEnabled,
staleTime: 60 * 60 * 1000,
});
const jellyfinServerId = serverInfo?.Id;
const {
data: recommendationIds,
isLoading: isLoadingRecommendations,
isError: isRecommendationsError,
} = useQuery({
queryKey: [
"streamystats",
"recommendations",
type,
jellyfinServerId,
settings?.streamyStatsServerUrl,
],
queryFn: async (): Promise<string[]> => {
if (
!settings?.streamyStatsServerUrl ||
!api?.accessToken ||
!jellyfinServerId
) {
return [];
}
const streamyStatsApi = createStreamystatsApi({
serverUrl: settings.streamyStatsServerUrl,
jellyfinToken: api.accessToken,
});
const response = await streamyStatsApi.getRecommendationIds(
jellyfinServerId,
type,
limit,
);
const data = response as StreamystatsRecommendationsIdsResponse;
if (type === "Movie") {
return data.data.movies || [];
}
return data.data.series || [];
},
enabled:
enabled &&
streamyStatsEnabled &&
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const {
data: items,
isLoading: isLoadingItems,
isError: isItemsError,
} = useQuery({
queryKey: [
"streamystats",
"recommendations",
"items",
type,
recommendationIds,
],
queryFn: async (): Promise<BaseItemDto[]> => {
if (!api || !user?.Id || !recommendationIds?.length) {
return [];
}
const response = await getItemsApi(api).getItems({
userId: user.Id,
ids: recommendationIds,
fields: ["PrimaryImageAspectRatio", "Genres"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response.data.Items || [];
},
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const isLoading = isLoadingRecommendations || isLoadingItems;
const isError = isRecommendationsError || isItemsError;
const handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
},
[from, router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_POSTER_WIDTH + ITEM_GAP,
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
const renderItem = useCallback(
({ item }: { item: BaseItemDto }) => {
return (
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[handleItemPress, onItemFocus],
);
if (!streamyStatsEnabled) return null;
if (isError) return null;
if (!isLoading && (!items || items.length === 0)) return null;
return (
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{title}
</Text>
{isLoading ? (
<View
style={{
flexDirection: "row",
gap: ITEM_GAP,
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
}}
>
{[1, 2, 3, 4, 5].map((i) => (
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
<View
style={{
backgroundColor: "#262626",
width: TV_POSTER_WIDTH,
aspectRatio: 10 / 15,
borderRadius: 12,
marginBottom: 8,
}}
/>
</View>
))}
</View>
) : (
<FlatList
horizontal
data={items}
keyExtractor={(item) => item.Id!}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={5}
removeClippedSubviews={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
)}
</View>
);
};

View File

@@ -0,0 +1,141 @@
import React, { useRef, useState } from "react";
import {
Animated,
Easing,
Pressable,
StyleSheet,
TextInput,
type TextInputProps,
} from "react-native";
import { Text } from "@/components/common/Text";
interface TVPinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
value: string;
onChangeText: (text: string) => void;
length?: number;
label?: string;
hasTVPreferredFocus?: boolean;
}
export interface TVPinInputRef {
focus: () => void;
}
const TVPinInputComponent = React.forwardRef<TVPinInputRef, TVPinInputProps>(
(props, ref) => {
const {
value,
onChangeText,
length = 4,
label,
hasTVPreferredFocus,
placeholder,
...rest
} = props;
const inputRef = useRef<TextInput>(null);
const [isFocused, setIsFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
}),
[],
);
const animateFocus = (focused: boolean) => {
Animated.timing(scale, {
toValue: focused ? 1.02 : 1,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
};
const handleChangeText = (text: string) => {
// Only allow numeric input and limit to length
const numericText = text.replace(/[^0-9]/g, "").slice(0, length);
onChangeText(numericText);
};
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
inputRef.current?.focus();
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
styles.container,
{
transform: [{ scale }],
borderColor: isFocused ? "#6366F1" : "#374151",
},
]}
>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput
ref={inputRef}
value={value}
onChangeText={handleChangeText}
keyboardType='number-pad'
maxLength={length}
secureTextEntry
placeholder={placeholder || `Enter ${length}-digit PIN`}
placeholderTextColor='#6B7280'
style={styles.input}
onFocus={() => {
setIsFocused(true);
animateFocus(true);
}}
onBlur={() => {
setIsFocused(false);
animateFocus(false);
}}
{...rest}
/>
</Animated.View>
</Pressable>
);
},
);
TVPinInputComponent.displayName = "TVPinInput";
export const TVPinInput = TVPinInputComponent;
const styles = StyleSheet.create({
container: {
backgroundColor: "#1F2937",
borderRadius: 12,
borderWidth: 2,
paddingHorizontal: 20,
paddingVertical: 4,
minWidth: 280,
},
label: {
fontSize: 14,
color: "rgba(255,255,255,0.6)",
marginBottom: 4,
marginTop: 8,
},
input: {
fontSize: 24,
color: "#fff",
fontWeight: "500",
textAlign: "center",
height: 56,
letterSpacing: 8,
},
});

View File

@@ -3,7 +3,7 @@ import type React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import PersonPoster from "@/components/seerr/PersonPoster"; import PersonPoster from "@/components/jellyseerr/PersonPoster";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
@@ -15,17 +15,19 @@ const CastSlide: React.FC<
details?.credits?.cast && details?.credits?.cast &&
details?.credits?.cast?.length > 0 && ( details?.credits?.cast?.length > 0 && (
<View {...props}> <View {...props}>
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text> <Text className='text-lg font-bold mb-2 px-4'>
{t("jellyseerr.cast")}
</Text>
<FlashList <FlashList
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
data={details?.credits.cast} data={details?.credits.cast}
ItemSeparatorComponent={() => <View className='w-2' />} ItemSeparatorComponent={() => <View className='w-2' />}
keyExtractor={(item) => item?.id?.toString() ?? ""} keyExtractor={(item) => item?.id?.toString()}
contentContainerStyle={{ paddingHorizontal: 16 }} contentContainerStyle={{ paddingHorizontal: 16 }}
renderItem={({ item }) => ( renderItem={({ item }) => (
<PersonPoster <PersonPoster
id={item?.id?.toString() ?? ""} id={item.id.toString()}
posterPath={item.profilePath} posterPath={item.profilePath}
name={item.name} name={item.name}
subName={item.character} subName={item.character}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import CountryFlag from "react-native-country-flag"; import CountryFlag from "react-native-country-flag";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces"; import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
@@ -50,7 +50,8 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC< const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => { > = ({ details, className, ...props }) => {
const { seerrRegion: region, seerrLocale: locale } = useSeerr(); const { jellyseerrRegion: region, jellyseerrLocale: locale } =
useJellyseerr();
const { t } = useTranslation(); const { t } = useTranslation();
const releases = useMemo( const releases = useMemo(
@@ -58,7 +59,7 @@ const DetailFacts: React.FC<
(details as MovieDetails)?.releases?.results.find( (details as MovieDetails)?.releases?.results.find(
(r: TmdbRelease) => r.iso_3166_1 === region, (r: TmdbRelease) => r.iso_3166_1 === region,
)?.release_dates as TmdbRelease["release_dates"], )?.release_dates as TmdbRelease["release_dates"],
[details, region], [details],
); );
// Release date types: // Release date types:
@@ -80,34 +81,40 @@ const DetailFacts: React.FC<
const firstAirDate = useMemo(() => { const firstAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate; const firstAirDate = (details as TvDetails)?.firstAirDate;
if (firstAirDate) { if (firstAirDate) {
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts); return new Date(firstAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts,
);
} }
}, [details, locale]); }, [details]);
const nextAirDate = useMemo(() => { const nextAirDate = useMemo(() => {
const firstAirDate = (details as TvDetails)?.firstAirDate; const firstAirDate = (details as TvDetails)?.firstAirDate;
const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate; const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
if (nextAirDate && firstAirDate !== nextAirDate) { if (nextAirDate && firstAirDate !== nextAirDate) {
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts); return new Date(nextAirDate).toLocaleDateString(
`${locale}-${region}`,
dateOpts,
);
} }
}, [details, locale]); }, [details]);
const revenue = useMemo( const revenue = useMemo(
() => () =>
(details as MovieDetails)?.revenue?.toLocaleString?.(locale, { (details as MovieDetails)?.revenue?.toLocaleString?.(
style: "currency", `${locale}-${region}`,
currency: "USD", { style: "currency", currency: "USD" },
}), ),
[details, locale], [details],
); );
const budget = useMemo( const budget = useMemo(
() => () =>
(details as MovieDetails)?.budget?.toLocaleString?.(locale, { (details as MovieDetails)?.budget?.toLocaleString?.(
style: "currency", `${locale}-${region}`,
currency: "USD", { style: "currency", currency: "USD" },
}), ),
[details, locale], [details],
); );
const streamingProviders = useMemo( const streamingProviders = useMemo(
@@ -115,7 +122,7 @@ const DetailFacts: React.FC<
details?.watchProviders?.find( details?.watchProviders?.find(
(provider) => provider.iso_3166_1 === region, (provider) => provider.iso_3166_1 === region,
)?.flatrate, )?.flatrate,
[details, region], [details],
); );
const networks = useMemo(() => (details as TvDetails)?.networks, [details]); const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
@@ -131,21 +138,21 @@ const DetailFacts: React.FC<
return ( return (
details && ( details && (
<View className='p-4'> <View className='p-4'>
<Text className='text-lg font-bold'>{t("seerr.details")}</Text> <Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
<View <View
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`} className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
{...props} {...props}
> >
<Fact title={t("seerr.status")} fact={details?.status} /> <Fact title={t("jellyseerr.status")} fact={details?.status} />
<Fact <Fact
title={t("seerr.original_title")} title={t("jellyseerr.original_title")}
fact={(details as TvDetails)?.originalName} fact={(details as TvDetails)?.originalName}
/> />
{details.keywords.some( {details.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID, (keyword) => keyword.id === ANIME_KEYWORD_ID,
) && <Fact title={t("seerr.series_type")} fact='Anime' />} ) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
<Facts <Facts
title={t("seerr.release_dates")} title={t("jellyseerr.release_dates")}
facts={filteredReleases?.map?.((r: Release, idx) => ( facts={filteredReleases?.map?.((r: Release, idx) => (
<View key={idx} className='flex flex-row space-x-2 items-center'> <View key={idx} className='flex flex-row space-x-2 items-center'>
{r.type === 3 ? ( {r.type === 3 ? (
@@ -164,20 +171,23 @@ const DetailFacts: React.FC<
)} )}
<Text> <Text>
{new Date(r.release_date).toLocaleDateString( {new Date(r.release_date).toLocaleDateString(
locale, `${locale}-${region}`,
dateOpts, dateOpts,
)} )}
</Text> </Text>
</View> </View>
))} ))}
/> />
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} /> <Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} /> <Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
<Fact title={t("seerr.revenue")} fact={revenue} /> <Fact title={t("jellyseerr.revenue")} fact={revenue} />
<Fact title={t("seerr.budget")} fact={budget} /> <Fact title={t("jellyseerr.budget")} fact={budget} />
<Fact title={t("seerr.original_language")} fact={spokenLanguage} /> <Fact
title={t("jellyseerr.original_language")}
fact={spokenLanguage}
/>
<Facts <Facts
title={t("seerr.production_country")} title={t("jellyseerr.production_country")}
facts={details?.productionCountries?.map((n, idx) => ( facts={details?.productionCountries?.map((n, idx) => (
<View key={idx} className='flex flex-row items-center space-x-2'> <View key={idx} className='flex flex-row items-center space-x-2'>
<CountryFlag isoCode={n.iso_3166_1} size={10} /> <CountryFlag isoCode={n.iso_3166_1} size={10} />
@@ -186,17 +196,17 @@ const DetailFacts: React.FC<
))} ))}
/> />
<Facts <Facts
title={t("seerr.studios")} title={t("jellyseerr.studios")}
facts={uniqBy(details?.productionCompanies, "name")?.map( facts={uniqBy(details?.productionCompanies, "name")?.map(
(n) => n.name, (n) => n.name,
)} )}
/> />
<Facts <Facts
title={t("seerr.network")} title={t("jellyseerr.network")}
facts={networks?.map((n) => n.name)} facts={networks?.map((n) => n.name)}
/> />
<Facts <Facts
title={t("seerr.currently_streaming_on")} title={t("jellyseerr.currently_streaming_on")}
facts={streamingProviders?.map((s) => s.name)} facts={streamingProviders?.map((s) => s.name)}
/> />
</View> </View>

View File

@@ -1,10 +1,16 @@
import React from "react";
import { View } from "react-native"; import { View } from "react-native";
interface Props {
index: number;
}
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it. // Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
export const GridSkeleton = React.memo(() => { export const GridSkeleton: React.FC<Props> = ({ index }) => {
return ( return (
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}> <View
key={index}
className='flex flex-col mr-2 h-auto'
style={{ width: "30.5%" }}
>
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' /> <View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
<View className='mt-2 flex flex-col w-full'> <View className='mt-2 flex flex-col w-full'>
<View className='h-4 bg-neutral-800 rounded mb-1' /> <View className='h-4 bg-neutral-800 rounded mb-1' />
@@ -12,4 +18,4 @@ export const GridSkeleton = React.memo(() => {
</View> </View>
</View> </View>
); );
}); };

View File

@@ -8,8 +8,8 @@ import {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import Discover from "@/components/seerr/discover/Discover"; import Discover from "@/components/jellyseerr/discover/Discover";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { import type {
MovieResult, MovieResult,
@@ -18,57 +18,57 @@ import type {
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery"; import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import SeerrPoster from "../posters/SeerrPoster"; import JellyseerrPoster from "../posters/JellyseerrPoster";
import { LoadingSkeleton } from "../search/LoadingSkeleton"; import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper"; import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster"; import PersonPoster from "./PersonPoster";
interface Props extends ViewProps { interface Props extends ViewProps {
searchQuery: string; searchQuery: string;
sortType?: SeerrSearchSort; sortType?: JellyseerrSearchSort;
order?: "asc" | "desc"; order?: "asc" | "desc";
} }
export enum SeerrSearchSort { export enum JellyseerrSearchSort {
DEFAULT = 0, DEFAULT = 0,
VOTE_COUNT_AND_AVERAGE = 1, VOTE_COUNT_AND_AVERAGE = 1,
POPULARITY = 2, POPULARITY = 2,
} }
export const SeerrIndexPage: React.FC<Props> = ({ export const JellyserrIndexPage: React.FC<Props> = ({
searchQuery, searchQuery,
sortType, sortType,
order, order,
}) => { }) => {
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
data: seerrDiscoverSettings, data: jellyseerrDiscoverSettings,
isFetching: f1, isFetching: f1,
isLoading: l1, isLoading: l1,
} = useReactNavigationQuery({ } = useReactNavigationQuery({
queryKey: ["search", "seerr", "discoverSettings", searchQuery], queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
queryFn: async () => seerrApi?.discoverSettings(), queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!seerrApi && searchQuery.length === 0, enabled: !!jellyseerrApi && searchQuery.length === 0,
}); });
const { const {
data: seerrResults, data: jellyseerrResults,
isFetching: f2, isFetching: f2,
isLoading: l2, isLoading: l2,
} = useReactNavigationQuery({ } = useReactNavigationQuery({
queryKey: ["search", "seerr", "results", searchQuery], queryKey: ["search", "jellyseerr", "results", searchQuery],
queryFn: async () => { queryFn: async () => {
const params = { const params = {
query: new URLSearchParams(searchQuery || "").toString(), query: new URLSearchParams(searchQuery || "").toString(),
}; };
return await Promise.all([ return await Promise.all([
seerrApi?.search({ ...params, page: 1 }), jellyseerrApi?.search({ ...params, page: 1 }),
seerrApi?.search({ ...params, page: 2 }), jellyseerrApi?.search({ ...params, page: 2 }),
seerrApi?.search({ ...params, page: 3 }), jellyseerrApi?.search({ ...params, page: 3 }),
seerrApi?.search({ ...params, page: 4 }), jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) => ]).then((all) =>
uniqBy( uniqBy(
all.flatMap((v) => v?.results || []), all.flatMap((v) => v?.results || []),
@@ -76,7 +76,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
), ),
); );
}, },
enabled: !!seerrApi && searchQuery.length > 0, enabled: !!jellyseerrApi && searchQuery.length > 0,
}); });
useAnimatedReaction( useAnimatedReaction(
@@ -92,20 +92,20 @@ export const SeerrIndexPage: React.FC<Props> = ({
const sortingType = useMemo(() => { const sortingType = useMemo(() => {
if (!sortType) return; if (!sortType) return;
switch (Number(SeerrSearchSort[sortType])) { switch (Number(JellyseerrSearchSort[sortType])) {
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE: case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
return ["voteCount", "voteAverage"]; return ["voteCount", "voteAverage"];
case SeerrSearchSort.POPULARITY: case JellyseerrSearchSort.POPULARITY:
return ["voteCount", "popularity"]; return ["voteCount", "popularity"];
default: default:
return undefined; return undefined;
} }
}, [sortType, order]); }, [sortType, order]);
const seerrMovieResults = useMemo( const jellyseerrMovieResults = useMemo(
() => () =>
orderBy( orderBy(
seerrResults?.filter( jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE, (r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[], ) as MovieResult[],
sortingType || [ sortingType || [
@@ -113,37 +113,41 @@ export const SeerrIndexPage: React.FC<Props> = ({
], ],
order || "desc", order || "desc",
), ),
[seerrResults, sortingType, order, searchQuery], [jellyseerrResults, sortingType, order],
); );
const seerrTvResults = useMemo( const jellyseerrTvResults = useMemo(
() => () =>
orderBy( orderBy(
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[], jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
sortingType || [ sortingType || [
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(), (t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
], ],
order || "desc", order || "desc",
), ),
[seerrResults, sortingType, order, searchQuery], [jellyseerrResults, sortingType, order],
); );
const seerrPersonResults = useMemo( const jellyseerrPersonResults = useMemo(
() => () =>
orderBy( orderBy(
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[], jellyseerrResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
sortingType || [ sortingType || [
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(), (p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
], ],
order || "desc", order || "desc",
), ),
[seerrResults, sortingType, order, searchQuery], [jellyseerrResults, sortingType, order],
); );
if (!searchQuery.length) if (!searchQuery.length)
return ( return (
<View className='flex flex-col'> <View className='flex flex-col'>
<Discover sliders={seerrDiscoverSettings} /> <Discover sliders={jellyseerrDiscoverSettings} />
</View> </View>
); );
@@ -151,9 +155,9 @@ export const SeerrIndexPage: React.FC<Props> = ({
<View> <View>
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} /> <LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
{!seerrMovieResults?.length && {!jellyseerrMovieResults?.length &&
!seerrTvResults?.length && !jellyseerrTvResults?.length &&
!seerrPersonResults?.length && !jellyseerrPersonResults?.length &&
!f1 && !f1 &&
!f2 && !f2 &&
!l1 && !l1 &&
@@ -171,21 +175,21 @@ export const SeerrIndexPage: React.FC<Props> = ({
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}> <View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper <SearchItemWrapper
header={t("search.request_movies")} header={t("search.request_movies")}
items={seerrMovieResults} items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => ( renderItem={(item: MovieResult) => (
<SeerrPoster item={item} key={item.id} /> <JellyseerrPoster item={item} key={item.id} />
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
header={t("search.request_series")} header={t("search.request_series")}
items={seerrTvResults} items={jellyseerrTvResults}
renderItem={(item: TvResult) => ( renderItem={(item: TvResult) => (
<SeerrPoster item={item} key={item.id} /> <JellyseerrPoster item={item} key={item.id} />
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
header={t("search.actors")} header={t("search.actors")}
items={seerrPersonResults} items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => ( renderItem={(item: PersonResult) => (
<PersonPoster <PersonPoster
className='mr-2' className='mr-2'

View File

@@ -3,11 +3,9 @@ import { useMemo } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({ const JellyseerrMediaIcon: React.FC<
mediaType, { mediaType: "tv" | "movie" } & ViewProps
className, > = ({ mediaType, className, ...props }) => {
...props
}) => {
const style = useMemo( const style = useMemo(
() => () =>
mediaType === MediaType.MOVIE mediaType === MediaType.MOVIE
@@ -31,4 +29,4 @@ const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
); );
}; };
export default SeerrMediaIcon; export default JellyseerrMediaIcon;

View File

@@ -9,7 +9,7 @@ interface Props {
onPress?: () => void; onPress?: () => void;
} }
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
mediaStatus, mediaStatus,
showRequestIcon, showRequestIcon,
onPress, onPress,
@@ -74,4 +74,4 @@ const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
); );
}; };
export default SeerrStatusIcon; export default JellyseerrStatusIcon;

View File

@@ -133,7 +133,7 @@ const ParallaxSlideShow = <T,>({
<View className='px-4'> <View className='px-4'>
<View className='flex flex-row flex-wrap'> <View className='flex flex-row flex-wrap'>
{Array.from({ length: 9 }, (_, i) => ( {Array.from({ length: 9 }, (_, i) => (
<GridSkeleton key={i} /> <GridSkeleton key={i} index={i} />
))} ))}
</View> </View>
</View> </View>

View File

@@ -4,7 +4,7 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster"; import Poster from "@/components/posters/Poster";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
interface Props { interface Props {
id: string; id: string;
@@ -20,7 +20,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
subName, subName,
...props ...props
}) => { }) => {
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
@@ -28,20 +28,20 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)} onPress={() =>
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
}
> >
<View className='flex flex-col w-28' {...props}> <View className='flex flex-col w-28' {...props}>
<Poster <Poster
id={id} id={id}
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")} url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
/> />
<Text className='mt-2'>{name}</Text> <Text className='mt-2'>{name}</Text>
{subName && <Text className='text-xs opacity-50'>{subName}</Text>} {subName && <Text className='text-xs opacity-50'>{subName}</Text>}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
return null;
}; };
export default PersonPoster; export default PersonPoster;

View File

@@ -12,7 +12,7 @@ import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown"; import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { import type {
QualityProfile, QualityProfile,
RootFolder, RootFolder,
@@ -38,23 +38,14 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id"> Props & Omit<ViewProps, "id">
>( >(
( (
{ { id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
id,
title,
requestBody,
type,
isAnime = false,
is4k,
onRequested,
onDismiss,
},
ref, ref,
) => { ) => {
const { seerrApi, seerrUser, requestMedia } = useSeerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({ const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id), mediaId: Number(id),
mediaType: type, mediaType: type,
userId: seerrUser?.id, userId: jellyseerrUser?.id,
}); });
const [qualityProfileOpen, setQualityProfileOpen] = useState(false); const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
@@ -74,17 +65,18 @@ const RequestModal = forwardRef<
}, [onDismiss]); }, [onDismiss]);
const { data: serviceSettings } = useQuery({ const { data: serviceSettings } = useQuery({
queryKey: ["seerr", "request", type, "service"], queryKey: ["jellyseerr", "request", type, "service"],
queryFn: async () => queryFn: async () =>
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"), jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
enabled: !!seerrApi && !!seerrUser, enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: "always", refetchOnMount: "always",
}); });
const { data: users } = useQuery({ const { data: users } = useQuery({
queryKey: ["seerr", "users"], queryKey: ["jellyseerr", "users"],
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }), queryFn: async () =>
enabled: !!seerrApi && !!seerrUser, jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser,
refetchOnMount: "always", refetchOnMount: "always",
}); });
@@ -95,7 +87,7 @@ const RequestModal = forwardRef<
const { data: defaultServiceDetails } = useQuery({ const { data: defaultServiceDetails } = useQuery({
queryKey: [ queryKey: [
"seerr", "jellyseerr",
"request", "request",
type, type,
"service", "service",
@@ -107,12 +99,12 @@ const RequestModal = forwardRef<
...prev, ...prev,
serverId: defaultService?.id, serverId: defaultService?.id,
})); }));
return seerrApi?.serviceDetails( return jellyseerrApi?.serviceDetails(
type === "movie" ? "radarr" : "sonarr", type === "movie" ? "radarr" : "sonarr",
defaultService!.id, defaultService!.id,
); );
}, },
enabled: !!seerrApi && !!seerrUser && !!defaultService, enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
refetchOnMount: "always", refetchOnMount: "always",
}); });
@@ -156,9 +148,9 @@ const RequestModal = forwardRef<
return undefined; return undefined;
} }
if (requestBody.seasons.length > 1) { if (requestBody.seasons.length > 1) {
return t("seerr.season_all"); return t("jellyseerr.season_all");
} }
return t("seerr.season_number", { return t("jellyseerr.season_number", {
season_number: requestBody.seasons[0], season_number: requestBody.seasons[0],
}); });
}, [requestBody?.seasons]); }, [requestBody?.seasons]);
@@ -253,7 +245,8 @@ const RequestModal = forwardRef<
type: "radio" as const, type: "radio" as const,
label: user.displayName, label: user.displayName,
value: user.id.toString(), value: user.id.toString(),
selected: (requestOverrides.userId || seerrUser?.id) === user.id, selected:
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
onPress: () => onPress: () =>
setRequestOverrides((prev) => ({ setRequestOverrides((prev) => ({
...prev, ...prev,
@@ -262,13 +255,12 @@ const RequestModal = forwardRef<
})) || [], })) || [],
}, },
], ],
[users, seerrUser, requestOverrides.userId], [users, jellyseerrUser, requestOverrides.userId],
); );
const request = useCallback(() => { const request = useCallback(() => {
const body = { const body = {
is4k: is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id, profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path, rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id), tags: defaultTags.map((t) => t.id),
@@ -276,7 +268,7 @@ const RequestModal = forwardRef<
...requestOverrides, ...requestOverrides,
}; };
writeDebugLog("Sending Seerr advanced request", body); writeDebugLog("Sending Jellyseerr advanced request", body);
requestMedia( requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title, seasonTitle ? `${title}, ${seasonTitle}` : title,
@@ -284,18 +276,11 @@ const RequestModal = forwardRef<
onRequested, onRequested,
); );
}, [ }, [
is4k,
defaultService?.is4k,
defaultServiceDetails?.server.is4k,
requestBody, requestBody,
requestOverrides, requestOverrides,
defaultProfile, defaultProfile,
defaultFolder, defaultFolder,
defaultTags, defaultTags,
requestMedia,
seasonTitle,
title,
onRequested,
]); ]);
return ( return (
@@ -323,7 +308,7 @@ const RequestModal = forwardRef<
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View> <View>
<Text className='font-bold text-2xl text-neutral-100'> <Text className='font-bold text-2xl text-neutral-100'>
{t("seerr.advanced")} {t("jellyseerr.advanced")}
</Text> </Text>
{seasonTitle && ( {seasonTitle && (
<Text className='text-neutral-300'>{seasonTitle}</Text> <Text className='text-neutral-300'>{seasonTitle}</Text>
@@ -334,7 +319,7 @@ const RequestModal = forwardRef<
<> <>
<View className='flex flex-col'> <View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("seerr.quality_profile")} {t("jellyseerr.quality_profile")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={qualityProfileOptions} groups={qualityProfileOptions}
@@ -350,7 +335,7 @@ const RequestModal = forwardRef<
</Text> </Text>
</View> </View>
} }
title={t("seerr.quality_profile")} title={t("jellyseerr.quality_profile")}
open={qualityProfileOpen} open={qualityProfileOpen}
onOpenChange={setQualityProfileOpen} onOpenChange={setQualityProfileOpen}
/> />
@@ -358,7 +343,7 @@ const RequestModal = forwardRef<
<View className='flex flex-col'> <View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("seerr.root_folder")} {t("jellyseerr.root_folder")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={rootFolderOptions} groups={rootFolderOptions}
@@ -383,17 +368,15 @@ const RequestModal = forwardRef<
</Text> </Text>
</View> </View>
} }
title={t("seerr.root_folder")} title={t("jellyseerr.root_folder")}
open={rootFolderOpen} open={rootFolderOpen}
onOpenChange={setRootFolderOpen} onOpenChange={setRootFolderOpen}
/> />
</View> </View>
{defaultServiceDetails?.tags &&
defaultServiceDetails.tags.length > 0 && (
<View className='flex flex-col'> <View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("seerr.tags")} {t("jellyseerr.tags")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={tagsOptions} groups={tagsOptions}
@@ -412,16 +395,15 @@ const RequestModal = forwardRef<
</Text> </Text>
</View> </View>
} }
title={t("seerr.tags")} title={t("jellyseerr.tags")}
open={tagsOpen} open={tagsOpen}
onOpenChange={setTagsOpen} onOpenChange={setTagsOpen}
/> />
</View> </View>
)}
<View className='flex flex-col'> <View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'> <Text className='opacity-50 mb-1 text-xs'>
{t("seerr.request_as")} {t("jellyseerr.request_as")}
</Text> </Text>
<PlatformDropdown <PlatformDropdown
groups={usersOptions} groups={usersOptions}
@@ -431,12 +413,12 @@ const RequestModal = forwardRef<
{users.find( {users.find(
(u) => (u) =>
u.id === u.id ===
(requestOverrides.userId || seerrUser?.id), (requestOverrides.userId || jellyseerrUser?.id),
)?.displayName || seerrUser!.displayName} )?.displayName || jellyseerrUser!.displayName}
</Text> </Text>
</View> </View>
} }
title={t("seerr.request_as")} title={t("jellyseerr.request_as")}
open={usersOpen} open={usersOpen}
onOpenChange={setUsersOpen} onOpenChange={setUsersOpen}
/> />
@@ -445,7 +427,7 @@ const RequestModal = forwardRef<
)} )}
</View> </View>
<Button className='mt-auto' onPress={request} color='purple'> <Button className='mt-auto' onPress={request} color='purple'>
{t("seerr.request_button")} {t("jellyseerr.request_button")}
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

@@ -2,10 +2,10 @@ import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { import {
COMPANY_LOGO_IMAGE_FILTER, COMPANY_LOGO_IMAGE_FILTER,
type Network, type Network,
@@ -16,17 +16,17 @@ const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps { data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => { > = ({ slide, data, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter(); const router = useRouter();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(
({ id, image, name }: Network | Studio) => ({ id, image, name }: Network | Studio) =>
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any, pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
params: { id, image, name, type: slide.type }, params: { id, image, name, type: slide.type },
}), }),
[router, from, slide.type], [slide],
); );
return ( return (
@@ -40,7 +40,10 @@ const CompanySlide: React.FC<
<GenericSlideCard <GenericSlideCard
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4' className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
id={item.id.toString()} id={item.id.toString()}
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)} url={jellyseerrApi?.imageProxy(
item.image,
COMPANY_LOGO_IMAGE_FILTER,
)}
/> />
</TouchableOpacity> </TouchableOpacity>
)} )}

View File

@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import CompanySlide from "@/components/seerr/discover/CompanySlide"; import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
import GenreSlide from "@/components/seerr/discover/GenreSlide"; import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide"; import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide"; import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
@@ -23,6 +23,7 @@ const Discover: React.FC<Props> = ({ sliders }) => {
sortBy( sortBy(
(sliders ?? []).filter((s) => s.enabled), (sliders ?? []).filter((s) => s.enabled),
"order", "order",
"asc",
), ),
[sliders], [sliders],
); );

View File

@@ -1,6 +1,6 @@
import { Image, type ImageContentFit } from "expo-image"; import { Image, type ImageContentFit } from "expo-image";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import React from "react"; import type React from "react";
import { StyleSheet, View, type ViewProps } from "react-native"; import { StyleSheet, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
</> </>
); );
export default React.memo(GenericSlideCard); export default GenericSlideCard;

View File

@@ -3,38 +3,39 @@ import { useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native"; import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard"; import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useSeerr } from "@/hooks/useSeerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants"; import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => { const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments(); const segments = useSegments();
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter(); const router = useRouter();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(
(genre: GenreSliderItem) => (genre: GenreSliderItem) =>
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any, pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name }, params: { type: slide.type, name: genre.name },
}), }),
[router, from, slide.type], [slide],
); );
const { data } = useQuery({ const { data } = useQuery({
queryKey: ["seerr", "discover", slide.type, slide.id], queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => { queryFn: async () => {
return seerrApi?.getGenreSliders( return jellyseerrApi?.getGenreSliders(
slide.type === DiscoverSliderType.MOVIE_GENRES slide.type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE ? Endpoints.MOVIE
: Endpoints.TV, : Endpoints.TV,
); );
}, },
enabled: !!seerrApi, enabled: !!jellyseerrApi,
}); });
return ( return (
@@ -52,7 +53,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
title={item.name} title={item.name}
colors={["transparent", "transparent"]} colors={["transparent", "transparent"]}
contentFit={"cover"} contentFit={"cover"}
url={seerrApi?.imageProxy( url={jellyseerrApi?.imageProxy(
item.backdrops?.[0], item.backdrops?.[0],
`w780_filter(duotone,${ `w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0] genreColorMap[item.id] ?? genreColorMap[0]

View File

@@ -3,19 +3,23 @@ import { uniqBy } from "lodash";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr"; import {
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide, slide,
...props ...props
}) => { }) => {
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "discover", slide.id], queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined; let endpoint: DiscoverEndpoint | undefined;
let params: any = { let params: any = {
@@ -46,13 +50,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
break; break;
} }
return endpoint ? seerrApi?.discover(endpoint, params) : null; return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
}, },
initialPageParam: 1, initialPageParam: 1,
getNextPageParam: (lastPage, pages) => getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) + (lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1, 1,
enabled: !!seerrApi, enabled: !!jellyseerrApi,
staleTime: 0, staleTime: 0,
}); });
@@ -61,10 +65,12 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))), .flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id", "id",
), ),
[data, isSeerrMovieOrTvResult], [data],
); );
return ( return (
@@ -78,7 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => { onEndReached={() => {
if (hasNextPage) fetchNextPage(); if (hasNextPage) fetchNextPage();
}} }}
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />} renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
/> />
) )
); );

View File

@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type React from "react"; import type React from "react";
import type { ViewProps } from "react-native"; import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster"; import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useSeerr } from "@/hooks/useSeerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common"; import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({ const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request, request,
}) => { }) => {
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({ const { data: details } = useQuery({
queryKey: [ queryKey: [
"seerr", "jellyseerr",
"detail", "detail",
request.media.mediaType, request.media.mediaType,
request.media.tmdbId, request.media.tmdbId,
], ],
queryFn: async () => { queryFn: async () => {
return request.media.mediaType === MediaType.MOVIE return request.media.mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(request.media.tmdbId) ? jellyseerrApi?.movieDetails(request.media.tmdbId)
: seerrApi?.tvDetails(request.media.tmdbId); : jellyseerrApi?.tvDetails(request.media.tmdbId);
}, },
enabled: !!seerrApi, enabled: !!jellyseerrApi,
refetchOnMount: true, refetchOnMount: true,
staleTime: 0, staleTime: 0,
}); });
const { data: refreshedRequest } = useQuery({ const { data: refreshedRequest } = useQuery({
queryKey: ["seerr", "requests", request.media.mediaType, request.id], queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
queryFn: async () => seerrApi?.getRequest(request.id), queryFn: async () => jellyseerrApi?.getRequest(request.id),
enabled: !!seerrApi, enabled: !!jellyseerrApi,
refetchOnMount: true, refetchOnMount: true,
refetchInterval: 5000, refetchInterval: 5000,
staleTime: 0, staleTime: 0,
}); });
return ( return (
<SeerrPoster <JellyseerrPoster
horizontal horizontal
showDownloadInfo showDownloadInfo
item={details} item={details}
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
slide, slide,
...props ...props
}) => { }) => {
const { seerrApi } = useSeerr(); const { jellyseerrApi } = useJellyseerr();
const { data: requests } = useQuery({ const { data: requests } = useQuery({
queryKey: ["seerr", "recent_requests"], queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => seerrApi?.requests(), queryFn: async () => jellyseerrApi?.requests(),
enabled: !!seerrApi, enabled: !!jellyseerrApi,
refetchOnMount: true, refetchOnMount: true,
staleTime: 0, staleTime: 0,
}); });

View File

@@ -14,7 +14,10 @@ export interface SlideProps {
interface Props<T> extends SlideProps { interface Props<T> extends SlideProps {
data: T[]; data: T[];
renderItem: (item: T, index: number) => React.ReactElement | null; renderItem: (
item: T,
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string; keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined; onEndReached?: (() => void) | null | undefined;
} }
@@ -44,6 +47,7 @@ const Slide = <T,>({
data={data} data={data}
onEndReachedThreshold={1} onEndReachedThreshold={1}
onEndReached={onEndReached} onEndReached={onEndReached}
//@ts-expect-error
renderItem={({ item, index }) => renderItem={({ item, index }) =>
item ? renderItem(item, index) : null item ? renderItem(item, index) : null
} }

View File

@@ -0,0 +1,47 @@
import { sortBy } from "lodash";
import React, { useMemo } from "react";
import { View } from "react-native";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { TVDiscoverSlide } from "./TVDiscoverSlide";
interface TVDiscoverProps {
sliders?: DiscoverSlider[];
}
// Only show movie/TV slides on TV - skip genres, networks, studios for now
const SUPPORTED_SLIDE_TYPES = [
DiscoverSliderType.TRENDING,
DiscoverSliderType.POPULAR_MOVIES,
DiscoverSliderType.UPCOMING_MOVIES,
DiscoverSliderType.POPULAR_TV,
DiscoverSliderType.UPCOMING_TV,
];
export const TVDiscover: React.FC<TVDiscoverProps> = ({ sliders }) => {
const sortedSliders = useMemo(
() =>
sortBy(
(sliders ?? []).filter(
(s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type),
),
"order",
"asc",
),
[sliders],
);
if (!sliders || sortedSliders.length === 0) return null;
return (
<View>
{sortedSliders.map((slide, index) => (
<TVDiscoverSlide
key={slide.id}
slide={slide}
isFirstSlide={index === 0}
/>
))}
</View>
);
};

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