Compare commits
115 Commits
refactor-c
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44caf4b1ff | ||
|
|
92c70fadd1 | ||
|
|
f637367b82 | ||
|
|
715764cef8 | ||
|
|
36d6686258 | ||
|
|
dca7cc99f2 | ||
|
|
875a017e8c | ||
|
|
0c6c20f563 | ||
|
|
2c9906377d | ||
|
|
d5f7a18fe5 | ||
|
|
4606b9718e | ||
|
|
c2d61654b0 | ||
|
|
2c6938c739 | ||
|
|
1f454c0f12 | ||
|
|
c215fda973 | ||
|
|
a852e2e769 | ||
|
|
29873e08d7 | ||
|
|
5ce5cc2d99 | ||
|
|
ae5a71ff29 | ||
|
|
0e3e8b8016 | ||
|
|
d07a521f60 | ||
|
|
566ff485fb | ||
|
|
3a4042efd5 | ||
|
|
fb9b4b6f2d | ||
|
|
1b80db678e | ||
|
|
093fcc6187 | ||
|
|
26e8489384 | ||
|
|
02a65059b9 | ||
|
|
be2fd53f31 | ||
|
|
be92b5d75e | ||
|
|
3f882ecade | ||
|
|
4b7007386f | ||
|
|
d2790f4997 | ||
|
|
096670a0c3 | ||
|
|
aa6b441dd1 | ||
|
|
d8512897ad | ||
|
|
11b6f16cd3 | ||
|
|
506d8b14dc | ||
|
|
a8acdf4299 | ||
|
|
2a9f4c2885 | ||
|
|
0353a718f3 | ||
|
|
e3b4952c60 | ||
|
|
5f44540b6f | ||
|
|
4705c9f4f9 | ||
|
|
2b36d4bc76 | ||
|
|
f4445c4152 | ||
|
|
16a236393d | ||
|
|
eeb4ef3008 | ||
|
|
a173db9180 | ||
|
|
a8c07a31d3 | ||
|
|
493df28b8d | ||
|
|
749473c1e8 | ||
|
|
f8d1fad6d5 | ||
|
|
81af2afef8 | ||
|
|
9ef79ef364 | ||
|
|
83babc2687 | ||
|
|
f9a3a1f9f6 | ||
|
|
0f076d197f | ||
|
|
d28b5411d5 | ||
|
|
1da49d29d7 | ||
|
|
7af4b913d7 | ||
|
|
a667723d93 | ||
|
|
94bfa26041 | ||
|
|
d545ca3584 | ||
|
|
773701d0c1 | ||
|
|
a3f7d0c275 | ||
|
|
5b7ded08cc | ||
|
|
60dd00ad7e | ||
|
|
ec653cae15 | ||
|
|
18bc45ea0a | ||
|
|
ebb33854d7 | ||
|
|
9efa2bbaa2 | ||
|
|
c515d037cf | ||
|
|
ee3a288fa0 | ||
|
|
c0171aa656 | ||
|
|
41d3e61261 | ||
|
|
8f74c3edc7 | ||
|
|
56ffec3173 | ||
|
|
9509a427c8 | ||
|
|
cfcfb486bf | ||
|
|
407ea69425 | ||
|
|
e1e91ea1a6 | ||
|
|
e7ea8a2c3b | ||
|
|
9f1791ce93 | ||
|
|
38cb7068ef | ||
|
|
cc154f0c16 | ||
|
|
866aa44277 | ||
|
|
ff3f88c53b | ||
|
|
3fd76b1356 | ||
|
|
a86df6c46b | ||
|
|
bdd284b9a6 | ||
|
|
fff7d4459f | ||
|
|
b85549016d | ||
|
|
6c35608404 | ||
|
|
74e3465a84 | ||
|
|
be32d933bb | ||
|
|
db89295d9b | ||
|
|
8d90fe3a8b | ||
|
|
4880392197 | ||
|
|
e10a99cc48 | ||
|
|
55b897883b | ||
|
|
fe26a74451 | ||
|
|
4cdbab7d19 | ||
|
|
3e695def23 | ||
|
|
15e4c18d54 | ||
|
|
87169480a1 | ||
|
|
bd9467b09e | ||
|
|
6216e7fdb7 | ||
|
|
6d2e897c9f | ||
|
|
ad5148daad | ||
|
|
c1e12d5898 | ||
|
|
7416c8297a | ||
|
|
9727bec7ab | ||
|
|
6ba767a848 | ||
|
|
4ad103acb6 |
@@ -25,3 +25,19 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
|
|||||||
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
|
- **Mark as played flow**: The "mark as played" button uses `PlayedStatus` component → `useMarkAsPlayed` hook → `usePlaybackManager.markItemPlayed()`. The hook does optimistic updates via `setQueriesData` before calling the API. Located in `components/PlayedStatus.tsx` and `hooks/useMarkAsPlayed.ts`. _(2026-01-10)_
|
||||||
|
|
||||||
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
|
- **Stack screen header configuration**: Sub-pages under `(home)` need explicit `Stack.Screen` entries in `app/(auth)/(tabs)/(home)/_layout.tsx` with `headerTransparent: Platform.OS === "ios"`, `headerBlurEffect: "none"`, and a back button. Without this, pages show with wrong header styling. _(2026-01-10)_
|
||||||
|
|
||||||
|
- **MPV tvOS player exit freeze**: On tvOS, `mpv_terminate_destroy` can deadlock if called while blocking the main thread (e.g., via `queue.sync`). The fix is to run `mpv_terminate_destroy` on `DispatchQueue.global()` asynchronously, allowing it to access main thread for AVFoundation/GPU cleanup. Send `quit` command and drain events first. Located in `modules/mpv-player/ios/MPVLayerRenderer.swift`. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
||||||
|
|
||||||
|
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
|
||||||
|
|
||||||
|
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||||
41
.github/copilot-instructions.md
vendored
@@ -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)
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
210
CLAUDE.md
@@ -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.
|
||||||
|
|||||||
23
app.json
@@ -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,8 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
|
["./plugins/withTVXcodeEnv.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -225,6 +233,7 @@ export default function IndexLayout() {
|
|||||||
name='settings/plugins/jellyseerr/page'
|
name='settings/plugins/jellyseerr/page'
|
||||||
options={{
|
options={{
|
||||||
title: "Jellyseerr",
|
title: "Jellyseerr",
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Home } from "../../../../components/home/Home";
|
import { Home } from "../../../../components/home/Home";
|
||||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { settings } = useSettings();
|
|
||||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
|
||||||
|
|
||||||
if (showLargeHomeCarousel) {
|
|
||||||
return <HomeWithCarousel />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Home />;
|
return <Home />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
449
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
AudioTranscodeMode,
|
||||||
|
TVTypographyScale,
|
||||||
|
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();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
const currentTypographyScale =
|
||||||
|
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Typography scale options
|
||||||
|
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_small"),
|
||||||
|
value: TVTypographyScale.Small,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_default"),
|
||||||
|
value: TVTypographyScale.Default,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_large"),
|
||||||
|
value: TVTypographyScale.Large,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.appearance.text_size_extra_large"),
|
||||||
|
value: TVTypographyScale.ExtraLarge,
|
||||||
|
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentTypographyScale],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get display labels for option buttons
|
||||||
|
const audioTranscodeLabel = useMemo(() => {
|
||||||
|
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.audio.transcode_mode.auto");
|
||||||
|
}, [audioTranscodeModeOptions, t]);
|
||||||
|
|
||||||
|
const subtitleModeLabel = useMemo(() => {
|
||||||
|
const option = subtitleModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.subtitles.modes.Default");
|
||||||
|
}, [subtitleModeOptions, t]);
|
||||||
|
|
||||||
|
const alignXLabel = useMemo(() => {
|
||||||
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Center";
|
||||||
|
}, [alignXOptions]);
|
||||||
|
|
||||||
|
const alignYLabel = useMemo(() => {
|
||||||
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Bottom";
|
||||||
|
}, [alignYOptions]);
|
||||||
|
|
||||||
|
const typographyScaleLabel = useMemo(() => {
|
||||||
|
const option = typographyScaleOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.appearance.text_size_default");
|
||||||
|
}, [typographyScaleOptions, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 120,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.title,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.settings_title")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 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: typography.callout - 2,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_hint") ||
|
||||||
|
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
|
||||||
|
</Text>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
|
||||||
|
}
|
||||||
|
value={openSubtitlesApiKey}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||||
|
"Enter API key..."
|
||||||
|
}
|
||||||
|
onChangeText={setOpenSubtitlesApiKey}
|
||||||
|
onBlur={() => updateSettings({ openSubtitlesApiKey })}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#6B7280",
|
||||||
|
fontSize: typography.callout - 4,
|
||||||
|
marginTop: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||||
|
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Appearance Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.appearance.text_size")}
|
||||||
|
value={typographyScaleLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.appearance.text_size"),
|
||||||
|
options: typographyScaleOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ tvTypographyScale: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching",
|
||||||
|
)}
|
||||||
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
|
value={settings.showHomeBackdrop}
|
||||||
|
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_hero_carousel")}
|
||||||
|
value={settings.showTVHeroCarousel}
|
||||||
|
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_series_poster_on_episode")}
|
||||||
|
value={settings.showSeriesPosterOnEpisode}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ showSeriesPosterOnEpisode: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useNavigation } from "expo-router";
|
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ListGroup } from "@/components/list/ListGroup";
|
|
||||||
import { ListItem } from "@/components/list/ListItem";
|
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
|
||||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create skip options for a specific segment type
|
|
||||||
* Reduces code duplication across all 5 segment types
|
|
||||||
*/
|
|
||||||
const useSkipOptions = (
|
|
||||||
settingKey:
|
|
||||||
| "skipIntro"
|
|
||||||
| "skipOutro"
|
|
||||||
| "skipRecap"
|
|
||||||
| "skipCommercial"
|
|
||||||
| "skipPreview",
|
|
||||||
settings: ReturnType<typeof useSettings>["settings"] | null,
|
|
||||||
updateSettings: ReturnType<typeof useSettings>["updateSettings"],
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
) => {
|
|
||||||
return useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
options: SEGMENT_SKIP_OPTIONS(t).map((option) => ({
|
|
||||||
type: "radio" as const,
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
selected: option.value === settings?.[settingKey],
|
|
||||||
onPress: () => updateSettings({ [settingKey]: option.value }),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[settings?.[settingKey], updateSettings, t, settingKey],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SegmentSkipPage() {
|
|
||||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
title: t("home.settings.other.segment_skip_settings"),
|
|
||||||
});
|
|
||||||
}, [navigation, t]);
|
|
||||||
|
|
||||||
const skipIntroOptions = useSkipOptions(
|
|
||||||
"skipIntro",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipOutroOptions = useSkipOptions(
|
|
||||||
"skipOutro",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipRecapOptions = useSkipOptions(
|
|
||||||
"skipRecap",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipCommercialOptions = useSkipOptions(
|
|
||||||
"skipCommercial",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const skipPreviewOptions = useSkipOptions(
|
|
||||||
"skipPreview",
|
|
||||||
settings,
|
|
||||||
updateSettings,
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DisabledSetting disabled={false} className='px-4'>
|
|
||||||
<ListGroup>
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_intro")}
|
|
||||||
subtitle={t("home.settings.other.skip_intro_description")}
|
|
||||||
disabled={pluginSettings?.skipIntro?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipIntroOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${settings.skipIntro}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_intro")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_outro")}
|
|
||||||
subtitle={t("home.settings.other.skip_outro_description")}
|
|
||||||
disabled={pluginSettings?.skipOutro?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipOutroOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${settings.skipOutro}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_outro")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_recap")}
|
|
||||||
subtitle={t("home.settings.other.skip_recap_description")}
|
|
||||||
disabled={pluginSettings?.skipRecap?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipRecapOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(`home.settings.other.segment_skip_${settings.skipRecap}`)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_recap")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_commercial")}
|
|
||||||
subtitle={t("home.settings.other.skip_commercial_description")}
|
|
||||||
disabled={pluginSettings?.skipCommercial?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipCommercialOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.other.segment_skip_${settings.skipCommercial}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_commercial")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListItem
|
|
||||||
title={t("home.settings.other.skip_preview")}
|
|
||||||
subtitle={t("home.settings.other.skip_preview_description")}
|
|
||||||
disabled={pluginSettings?.skipPreview?.locked}
|
|
||||||
>
|
|
||||||
<PlatformDropdown
|
|
||||||
groups={skipPreviewOptions}
|
|
||||||
trigger={
|
|
||||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
|
||||||
<Text className='mr-1 text-[#8E8D91]'>
|
|
||||||
{t(
|
|
||||||
`home.settings.other.segment_skip_${settings.skipPreview}`,
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name='chevron-expand-sharp'
|
|
||||||
size={18}
|
|
||||||
color='#5A5960'
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={t("home.settings.other.skip_preview")}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</ListGroup>
|
|
||||||
</DisabledSetting>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEGMENT_SKIP_OPTIONS = (
|
|
||||||
t: TFunction<"translation", undefined>,
|
|
||||||
): Array<{
|
|
||||||
label: string;
|
|
||||||
value: "none" | "ask" | "auto";
|
|
||||||
}> => [
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_auto"),
|
|
||||||
value: "auto",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_ask"),
|
|
||||||
value: "ask",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t("home.settings.other.segment_skip_none"),
|
|
||||||
value: "none",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -15,14 +15,29 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, Platform, 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 { 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 {
|
||||||
|
TVFilterButton,
|
||||||
|
TVFocusablePoster,
|
||||||
|
TVItemCardText,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +51,10 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -44,11 +63,15 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -56,7 +79,7 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -71,6 +94,46 @@ const page: React.FC = () => {
|
|||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: collectionId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
@@ -87,6 +150,18 @@ const page: React.FC = () => {
|
|||||||
setSortBy([sortByOption]);
|
setSortBy([sortByOption]);
|
||||||
}, [navigation, collection]);
|
}, [navigation, collection]);
|
||||||
|
|
||||||
|
// Calculate columns for TV grid
|
||||||
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
|
||||||
|
}, [screenWidth, orientation]);
|
||||||
|
|
||||||
const fetchItems = useCallback(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -98,7 +173,7 @@ const page: React.FC = () => {
|
|||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: 18,
|
limit: Platform.isTV ? 36 : 18,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
sortBy: [sortBy[0]],
|
sortBy: [sortBy[0]],
|
||||||
@@ -123,6 +198,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collection,
|
collection,
|
||||||
|
collectionId,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -131,39 +207,40 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
queryKey: [
|
useInfiniteQuery({
|
||||||
"collection-items",
|
queryKey: [
|
||||||
collection,
|
"collection-items",
|
||||||
selectedGenres,
|
collectionId,
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
],
|
sortOrder,
|
||||||
queryFn: fetchItems,
|
],
|
||||||
getNextPageParam: (lastPage, pages) => {
|
queryFn: fetchItems,
|
||||||
if (
|
getNextPageParam: (lastPage, pages) => {
|
||||||
!lastPage?.Items ||
|
if (
|
||||||
!lastPage?.TotalRecordCount ||
|
!lastPage?.Items ||
|
||||||
lastPage?.TotalRecordCount === 0
|
!lastPage?.TotalRecordCount ||
|
||||||
)
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
},
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
initialPageParam: 0,
|
||||||
const accumulatedItems = pages.reduce(
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
});
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
|
||||||
return lastPage?.Items?.length * pages.length;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +272,6 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
{/* <MoviePoster item={item} /> */}
|
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
@@ -203,9 +279,38 @@ const page: React.FC = () => {
|
|||||||
[orientation],
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
const _insets = useSafeAreaInsets();
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster onPress={handlePress}>
|
||||||
|
{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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
@@ -372,48 +477,315 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
// TV filter bar state
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
|
||||||
|
|
||||||
|
if (isLoading || isCollectionLoading) {
|
||||||
|
return (
|
||||||
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
// Mobile return
|
||||||
<FlashList
|
if (!Platform.isTV) {
|
||||||
ListEmptyComponent={
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<FlashList
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
ListEmptyComponent={
|
||||||
{t("search.no_results")}
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
</View>
|
{t("search.no_results")}
|
||||||
}
|
</Text>
|
||||||
extraData={[
|
</View>
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
}
|
||||||
}}
|
extraData={[
|
||||||
onEndReachedThreshold={0.5}
|
selectedGenres,
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
selectedYears,
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
selectedTags,
|
||||||
ItemSeparatorComponent={() => (
|
sortBy,
|
||||||
<View
|
sortOrder,
|
||||||
style={{
|
]}
|
||||||
width: 10,
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
height: 10,
|
data={flatData}
|
||||||
}}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<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}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
<TVFilterButton
|
||||||
/>
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,31 +66,46 @@ const Page: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={isOffline}>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<View className='flex flex-1 relative'>
|
<View className='flex flex-1 relative'>
|
||||||
<Animated.View
|
{/* Always render ItemContent - it handles loading state internally on TV */}
|
||||||
pointerEvents={"none"}
|
<ItemContent
|
||||||
style={[animatedStyle]}
|
item={item}
|
||||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
itemWithSources={itemWithSources}
|
||||||
>
|
isLoading={isLoading}
|
||||||
<View
|
/>
|
||||||
style={{
|
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
{/* Skeleton overlay - fades out when content loads */}
|
||||||
}}
|
{!item && (
|
||||||
className='bg-transparent rounded-lg mb-4 w-full'
|
<Animated.View
|
||||||
/>
|
pointerEvents={"none"}
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
style={[animatedStyle]}
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen z-50 bg-black'
|
||||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
>
|
||||||
<View className='flex flex-row space-x-1 mb-8'>
|
{Platform.isTV && ItemContentSkeletonTV ? (
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<ItemContentSkeletonTV />
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
) : (
|
||||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
<View style={{ paddingHorizontal: 16, width: "100%" }}>
|
||||||
</View>
|
<View
|
||||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
style={{
|
||||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
height: 450,
|
||||||
<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' />
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
</Animated.View>
|
/>
|
||||||
{item && <ItemContent item={item} itemWithSources={itemWithSources} />}
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
</View>
|
||||||
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</OfflineModeProvider>
|
</OfflineModeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { GenreTags } from "@/components/GenreTags";
|
|||||||
import Cast from "@/components/jellyseerr/Cast";
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
|
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||||
@@ -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();
|
||||||
@@ -542,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -61,6 +62,7 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +118,8 @@ const page: React.FC = () => {
|
|||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
),
|
),
|
||||||
staleTime: isOffline ? Infinity : 60,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,6 +162,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
|
||||||
|
|||||||
@@ -15,16 +15,37 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { 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 {
|
||||||
|
TVFilterButton,
|
||||||
|
TVFocusablePoster,
|
||||||
|
TVItemCardText,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +69,11 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
const _TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
@@ -58,6 +84,7 @@ const Page = () => {
|
|||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
@@ -79,6 +106,48 @@ const Page = () => {
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// 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 +231,10 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -322,7 +395,37 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation],
|
[orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(libraries)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster onPress={handlePress}>
|
||||||
|
{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],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -509,6 +612,188 @@ const Page = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter bar header
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
filterBy.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
_setFilterBy([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvFilterByOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: filterBy.length === 0,
|
||||||
|
},
|
||||||
|
...generalFilters.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: filterBy.includes(option.key),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[filterBy, generalFilters, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
const handleShowFilterByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.filter_by"),
|
||||||
|
options: tvFilterByOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
_setFilterBy([]);
|
||||||
|
} else {
|
||||||
|
setFilter([value as FilterByOption]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
@@ -518,43 +803,176 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mobile return
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
key={orientation}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<ScrollView
|
||||||
key={orientation}
|
style={{ flex: 1 }}
|
||||||
ListEmptyComponent={
|
contentContainerStyle={{
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
paddingTop: insets.top + 100,
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
paddingBottom: insets.bottom + 60,
|
||||||
{t("library.no_results")}
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
</Text>
|
}}
|
||||||
</View>
|
onScroll={({ nativeEvent }) => {
|
||||||
}
|
// Load more when near bottom
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
data={flatData}
|
const isNearBottom =
|
||||||
renderItem={renderItem}
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
extraData={[orientation, nrOfCols]}
|
contentSize.height - 500;
|
||||||
keyExtractor={keyExtractor}
|
if (isNearBottom && hasNextPage && !isFetching) {
|
||||||
numColumns={nrOfCols}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onEndReachedThreshold={1}
|
scrollEventThrottle={400}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
>
|
||||||
contentContainerStyle={{
|
{/* Filter bar */}
|
||||||
paddingBottom: 24,
|
<View
|
||||||
paddingLeft: insets.left,
|
style={{
|
||||||
paddingRight: insets.right,
|
flexDirection: "row",
|
||||||
}}
|
flexWrap: "nowrap",
|
||||||
ItemSeparatorComponent={() => (
|
justifyContent: "center",
|
||||||
|
paddingBottom: 24,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.filter_by")}
|
||||||
|
value={
|
||||||
|
filterBy.length > 0
|
||||||
|
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowFilterByFilter}
|
||||||
|
hasActiveFilter={filterBy.length > 0}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{flatData.length === 0 ? (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
flex: 1,
|
||||||
height: 10,
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flatData.map((item) => renderTVItem(item))}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isFetching && (
|
||||||
|
<View style={{ paddingVertical: 20 }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
return <Libraries />;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ["user-views", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await getUserViewsApi(api!).getUserViews({
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
staleTime: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const libraries = useMemo(
|
|
||||||
() =>
|
|
||||||
data
|
|
||||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
|
||||||
.filter((l) => l.CollectionType !== "books") || [],
|
|
||||||
[data, settings?.hiddenLibraries],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (const item of data || []) {
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: ["library", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id || !user?.Id || !api) return null;
|
|
||||||
const response = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: item.Id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<View className='justify-center items-center h-full'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!libraries)
|
|
||||||
return (
|
|
||||||
<View className='h-full w-full flex justify-center items-center'>
|
|
||||||
<Text className='text-lg text-neutral-500'>
|
|
||||||
{t("library.no_libraries_found")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlashList
|
|
||||||
extraData={settings}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingTop: Platform.OS === "android" ? 17 : 0,
|
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
|
||||||
paddingBottom: 150,
|
|
||||||
paddingLeft: insets.left + 17,
|
|
||||||
paddingRight: insets.right + 17,
|
|
||||||
}}
|
|
||||||
data={libraries}
|
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
ItemSeparatorComponent={() =>
|
|
||||||
settings?.libraryOptions?.display === "row" ? (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
}}
|
|
||||||
className='bg-neutral-800 mx-2 my-4'
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className='h-4' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +23,11 @@ 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 {
|
import {
|
||||||
JellyseerrSearchSort,
|
JellyseerrSearchSort,
|
||||||
@@ -36,12 +39,19 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
|||||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||||
|
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { 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";
|
||||||
@@ -59,6 +69,8 @@ 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);
|
||||||
|
|
||||||
@@ -199,9 +211,7 @@ export default function search() {
|
|||||||
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=")}`;
|
||||||
|
|
||||||
@@ -440,6 +450,179 @@ export default function search() {
|
|||||||
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||||
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||||
|
|
||||||
|
// TV item press handler
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Jellyseerr search for TV
|
||||||
|
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = {
|
||||||
|
query: new URLSearchParams(debouncedSearch || "").toString(),
|
||||||
|
};
|
||||||
|
return await Promise.all([
|
||||||
|
jellyseerrApi?.search({ ...params, page: 1 }),
|
||||||
|
jellyseerrApi?.search({ ...params, page: 2 }),
|
||||||
|
jellyseerrApi?.search({ ...params, page: 3 }),
|
||||||
|
jellyseerrApi?.search({ ...params, page: 4 }),
|
||||||
|
]).then((all) =>
|
||||||
|
uniqBy(
|
||||||
|
all.flatMap((v) => v?.results || []),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Platform.isTV &&
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process Jellyseerr results for TV
|
||||||
|
const jellyseerrMovieResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.MOVIE,
|
||||||
|
) as MovieResult[],
|
||||||
|
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTvResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === MediaType.TV,
|
||||||
|
) as TvResult[],
|
||||||
|
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrPersonResults = useMemo(
|
||||||
|
() =>
|
||||||
|
orderBy(
|
||||||
|
jellyseerrTVResults?.filter(
|
||||||
|
(r) => r.mediaType === "person",
|
||||||
|
) as PersonResult[],
|
||||||
|
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
[jellyseerrTVResults, debouncedSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jellyseerrTVNoResults = useMemo(() => {
|
||||||
|
return (
|
||||||
|
!jellyseerrMovieResults?.length &&
|
||||||
|
!jellyseerrTvResults?.length &&
|
||||||
|
!jellyseerrPersonResults?.length
|
||||||
|
);
|
||||||
|
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
|
||||||
|
|
||||||
|
// Fetch discover settings for TV (when no search query in Discover mode)
|
||||||
|
const { data: discoverSliders } = useQuery({
|
||||||
|
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
|
||||||
|
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||||
|
enabled:
|
||||||
|
Platform.isTV &&
|
||||||
|
!!jellyseerrApi &&
|
||||||
|
searchType === "Discover" &&
|
||||||
|
debouncedSearch.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TV Jellyseerr press handlers
|
||||||
|
const handleJellyseerrMoviePress = useCallback(
|
||||||
|
(item: MovieResult) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
mediaTitle: item.title,
|
||||||
|
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
|
||||||
|
canRequest: "true",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
|
||||||
|
mediaType: MediaType.MOVIE,
|
||||||
|
id: String(item.id),
|
||||||
|
backdropPath: item.backdropPath || "",
|
||||||
|
overview: item.overview || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, jellyseerrApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJellyseerrTvPress = useCallback(
|
||||||
|
(item: TvResult) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
mediaTitle: item.name,
|
||||||
|
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
|
||||||
|
canRequest: "true",
|
||||||
|
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
id: String(item.id),
|
||||||
|
backdropPath: item.backdropPath || "",
|
||||||
|
overview: item.overview || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, jellyseerrApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJellyseerrPersonPress = useCallback(
|
||||||
|
(item: PersonResult) => {
|
||||||
|
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render TV search page
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<TVSearchPage
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
debouncedSearch={debouncedSearch}
|
||||||
|
movies={movies}
|
||||||
|
series={series}
|
||||||
|
episodes={episodes}
|
||||||
|
collections={collections}
|
||||||
|
actors={actors}
|
||||||
|
artists={artists}
|
||||||
|
albums={albums}
|
||||||
|
songs={songs}
|
||||||
|
playlists={playlists}
|
||||||
|
loading={loading}
|
||||||
|
noResults={noResults}
|
||||||
|
onItemPress={handleItemPress}
|
||||||
|
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'
|
||||||
@@ -450,26 +633,6 @@ export default function search() {
|
|||||||
paddingBottom: 60,
|
paddingBottom: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* <View
|
|
||||||
className='flex flex-col'
|
|
||||||
style={{
|
|
||||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
|
||||||
}}
|
|
||||||
> */}
|
|
||||||
{Platform.isTV && (
|
|
||||||
<Input
|
|
||||||
placeholder={t("search.search")}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
router.setParams({ q: "" });
|
|
||||||
setSearch(text);
|
|
||||||
}}
|
|
||||||
keyboardType='default'
|
|
||||||
returnKeyType='done'
|
|
||||||
autoCapitalize='none'
|
|
||||||
clearButtonMode='while-editing'
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View
|
<View
|
||||||
className='flex flex-col'
|
className='flex flex-col'
|
||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
|
|||||||
21
app/(auth)/(tabs)/(settings)/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(auth)/(tabs)/(settings)/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SettingsTV from "@/app/(auth)/(tabs)/(home)/settings.tv";
|
||||||
|
|
||||||
|
export default function SettingsTabScreen() {
|
||||||
|
return <SettingsTV />;
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -16,9 +18,18 @@ 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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +43,36 @@ import {
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 20;
|
||||||
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -47,6 +87,8 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
|
if (Platform.isTV) return 1;
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -153,6 +195,36 @@ export default function WatchlistDetailScreen() {
|
|||||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
(item: BaseItemDto, index: number) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.Id}
|
||||||
|
style={{
|
||||||
|
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} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, typography],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -266,6 +338,120 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV layout with ScrollView + flexWrap
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 100,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={20} color='#9ca3af' />
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => renderTVItem(item, index))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile layout with FlashList
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -11,13 +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 { CastingMiniPlayer } from "@/components/casting/CastingMiniPlayer";
|
|
||||||
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<
|
||||||
@@ -118,8 +123,18 @@ 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>
|
||||||
<CastingMiniPlayer />
|
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -84,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);
|
||||||
@@ -126,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
|
||||||
@@ -149,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) {
|
||||||
@@ -160,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,
|
||||||
@@ -247,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;
|
||||||
@@ -273,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
|
||||||
@@ -297,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) {
|
||||||
@@ -305,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,
|
||||||
@@ -737,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);
|
||||||
@@ -826,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;
|
||||||
@@ -964,37 +1147,71 @@ export default function page() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isMounted === true && item && !isPipMode && (
|
{isMounted === true &&
|
||||||
<Controls
|
item &&
|
||||||
mediaSource={stream?.mediaSource}
|
!isPipMode &&
|
||||||
item={item}
|
(Platform.isTV ? (
|
||||||
togglePlay={togglePlay}
|
<TVControls
|
||||||
isPlaying={isPlaying}
|
mediaSource={stream?.mediaSource}
|
||||||
isSeeking={isSeeking}
|
item={item}
|
||||||
progress={progress}
|
togglePlay={togglePlay}
|
||||||
cacheProgress={cacheProgress}
|
isPlaying={isPlaying}
|
||||||
isBuffering={isBuffering}
|
isSeeking={isSeeking}
|
||||||
showControls={showControls}
|
progress={progress}
|
||||||
setShowControls={setShowControls}
|
cacheProgress={cacheProgress}
|
||||||
startPictureInPicture={startPictureInPicture}
|
isBuffering={isBuffering}
|
||||||
play={play}
|
showControls={showControls}
|
||||||
pause={pause}
|
setShowControls={setShowControls}
|
||||||
seek={seek}
|
play={play}
|
||||||
enableTrickplay={true}
|
pause={pause}
|
||||||
aspectRatio={aspectRatio}
|
seek={seek}
|
||||||
isZoomedToFill={isZoomedToFill}
|
audioIndex={currentAudioIndex}
|
||||||
onZoomToggle={handleZoomToggle}
|
subtitleIndex={currentSubtitleIndex}
|
||||||
api={api}
|
onAudioIndexChange={handleAudioIndexChange}
|
||||||
downloadedFiles={downloadedFiles}
|
onSubtitleIndexChange={handleSubtitleIndexChange}
|
||||||
playbackSpeed={currentPlaybackSpeed}
|
previousItem={previousItem}
|
||||||
setPlaybackSpeed={handleSetPlaybackSpeed}
|
nextItem={nextItem}
|
||||||
showTechnicalInfo={showTechnicalInfo}
|
goToPreviousItem={goToPreviousItem}
|
||||||
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
goToNextItem={goToNextItem}
|
||||||
getTechnicalInfo={getTechnicalInfo}
|
onRefreshSubtitleTracks={handleRefreshSubtitleTracks}
|
||||||
playMethod={playMethod}
|
addSubtitleFile={addSubtitleFile}
|
||||||
transcodeReasons={transcodeReasons}
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
/>
|
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||||
)}
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Controls
|
||||||
|
mediaSource={stream?.mediaSource}
|
||||||
|
item={item}
|
||||||
|
togglePlay={togglePlay}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isSeeking={isSeeking}
|
||||||
|
progress={progress}
|
||||||
|
cacheProgress={cacheProgress}
|
||||||
|
isBuffering={isBuffering}
|
||||||
|
showControls={showControls}
|
||||||
|
setShowControls={setShowControls}
|
||||||
|
startPictureInPicture={startPictureInPicture}
|
||||||
|
play={play}
|
||||||
|
pause={pause}
|
||||||
|
seek={seek}
|
||||||
|
enableTrickplay={true}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
isZoomedToFill={isZoomedToFill}
|
||||||
|
onZoomToggle={handleZoomToggle}
|
||||||
|
api={api}
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
|
playbackSpeed={currentPlaybackSpeed}
|
||||||
|
setPlaybackSpeed={handleSetPlaybackSpeed}
|
||||||
|
showTechnicalInfo={showTechnicalInfo}
|
||||||
|
onToggleTechnicalInfo={handleToggleTechnicalInfo}
|
||||||
|
getTechnicalInfo={getTechnicalInfo}
|
||||||
|
playMethod={playMethod}
|
||||||
|
transcodeReasons={transcodeReasons}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</VideoProvider>
|
</VideoProvider>
|
||||||
</PlayerProvider>
|
</PlayerProvider>
|
||||||
|
|||||||
171
app/(auth)/tv-option-modal.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
496
app/(auth)/tv-request-modal.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
||||||
|
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||||
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||||
|
import type {
|
||||||
|
QualityProfile,
|
||||||
|
RootFolder,
|
||||||
|
Tag,
|
||||||
|
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVRequestModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvRequestModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
|
mediaId: modalState?.id ? Number(modalState.id) : 0,
|
||||||
|
mediaType: modalState?.mediaType,
|
||||||
|
userId: jellyseerrUser?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeSelector, setActiveSelector] = useState<
|
||||||
|
"profile" | "folder" | "user" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvRequestModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
const { data: serviceSettings } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.service(
|
||||||
|
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "users"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultService = useMemo(
|
||||||
|
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||||
|
[serviceSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: defaultServiceDetails } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"jellyseerr",
|
||||||
|
"request",
|
||||||
|
modalState?.mediaType,
|
||||||
|
"service",
|
||||||
|
"details",
|
||||||
|
defaultService?.id,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverId: defaultService?.id,
|
||||||
|
}));
|
||||||
|
return jellyseerrApi?.serviceDetails(
|
||||||
|
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
defaultService!.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProfile: QualityProfile | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultFolder: RootFolder | undefined = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
|
||||||
|
),
|
||||||
|
[defaultServiceDetails],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultTags: Tag[] = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.filter((t) =>
|
||||||
|
defaultServiceDetails?.server.activeTags?.includes(t.id),
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails]);
|
||||||
|
|
||||||
|
const pathTitleExtractor = (item: RootFolder) =>
|
||||||
|
`${item.path} (${item.freeSpace.bytesToReadable()})`;
|
||||||
|
|
||||||
|
// Option builders
|
||||||
|
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.profiles.map((profile) => ({
|
||||||
|
label: profile.name,
|
||||||
|
value: profile.id,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
defaultProfile,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
defaultServiceDetails?.rootFolders.map((folder) => ({
|
||||||
|
label: pathTitleExtractor(folder),
|
||||||
|
value: folder.path,
|
||||||
|
selected:
|
||||||
|
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
|
||||||
|
})) || [],
|
||||||
|
[
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
defaultFolder,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const userOptions: TVOptionItem<number>[] = useMemo(
|
||||||
|
() =>
|
||||||
|
users?.map((user) => ({
|
||||||
|
label: user.displayName,
|
||||||
|
value: user.id,
|
||||||
|
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||||
|
})) || [],
|
||||||
|
[users, jellyseerrUser, requestOverrides.userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagItems = useMemo(() => {
|
||||||
|
return (
|
||||||
|
defaultServiceDetails?.tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
label: tag.label,
|
||||||
|
selected:
|
||||||
|
requestOverrides.tags?.includes(tag.id) ||
|
||||||
|
defaultTags.some((dt) => dt.id === tag.id),
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
|
||||||
|
|
||||||
|
// Selected display values
|
||||||
|
const selectedProfileName = useMemo(() => {
|
||||||
|
const profile = defaultServiceDetails?.profiles.find(
|
||||||
|
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
|
||||||
|
);
|
||||||
|
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.profiles,
|
||||||
|
requestOverrides.profileId,
|
||||||
|
defaultProfile,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedFolderName = useMemo(() => {
|
||||||
|
const folder = defaultServiceDetails?.rootFolders.find(
|
||||||
|
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
|
||||||
|
);
|
||||||
|
return folder
|
||||||
|
? pathTitleExtractor(folder)
|
||||||
|
: defaultFolder
|
||||||
|
? pathTitleExtractor(defaultFolder)
|
||||||
|
: t("jellyseerr.select");
|
||||||
|
}, [
|
||||||
|
defaultServiceDetails?.rootFolders,
|
||||||
|
requestOverrides.rootFolder,
|
||||||
|
defaultFolder,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedUserName = useMemo(() => {
|
||||||
|
const user = users?.find(
|
||||||
|
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
|
||||||
|
);
|
||||||
|
}, [users, requestOverrides.userId, jellyseerrUser, t]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleProfileChange = useCallback((profileId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUserChange = useCallback((userId: number) => {
|
||||||
|
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||||
|
setActiveSelector(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTagToggle = useCallback(
|
||||||
|
(tagId: number) => {
|
||||||
|
setRequestOverrides((prev) => {
|
||||||
|
const currentTags = prev.tags || defaultTags.map((t) => t.id);
|
||||||
|
const hasTag = currentTags.includes(tagId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
tags: hasTag
|
||||||
|
? currentTags.filter((id) => id !== tagId)
|
||||||
|
: [...currentTags, tagId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaultTags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRequest = useCallback(() => {
|
||||||
|
if (!modalState) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
|
profileId: defaultProfile?.id,
|
||||||
|
rootFolder: defaultFolder?.path,
|
||||||
|
tags: defaultTags.map((t) => t.id),
|
||||||
|
...modalState.requestBody,
|
||||||
|
...requestOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const seasonTitle =
|
||||||
|
modalState.requestBody?.seasons?.length === 1
|
||||||
|
? t("jellyseerr.season_number", {
|
||||||
|
season_number: modalState.requestBody.seasons[0],
|
||||||
|
})
|
||||||
|
: modalState.requestBody?.seasons &&
|
||||||
|
modalState.requestBody.seasons.length > 1
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
requestMedia(
|
||||||
|
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
|
||||||
|
body,
|
||||||
|
() => {
|
||||||
|
modalState.onRequested();
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
modalState,
|
||||||
|
requestOverrides,
|
||||||
|
defaultProfile,
|
||||||
|
defaultFolder,
|
||||||
|
defaultTags,
|
||||||
|
defaultService,
|
||||||
|
defaultServiceDetails,
|
||||||
|
requestMedia,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDataLoaded = defaultService && defaultServiceDetails && users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isDataLoaded && isReady ? (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.quality_profile")}
|
||||||
|
value={selectedProfileName}
|
||||||
|
onPress={() => setActiveSelector("profile")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.root_folder")}
|
||||||
|
value={selectedFolderName}
|
||||||
|
onPress={() => setActiveSelector("folder")}
|
||||||
|
/>
|
||||||
|
<TVRequestOptionRow
|
||||||
|
label={t("jellyseerr.request_as")}
|
||||||
|
value={selectedUserName}
|
||||||
|
onPress={() => setActiveSelector("user")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tagItems.length > 0 && (
|
||||||
|
<TVToggleOptionRow
|
||||||
|
label={t("jellyseerr.tags")}
|
||||||
|
items={tagItems}
|
||||||
|
onToggle={handleTagToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>{t("common.loading")}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={!isDataLoaded}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonText,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Sub-selectors */}
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "profile"}
|
||||||
|
title={t("jellyseerr.quality_profile")}
|
||||||
|
options={qualityProfileOptions}
|
||||||
|
onSelect={handleProfileChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "folder"}
|
||||||
|
title={t("jellyseerr.root_folder")}
|
||||||
|
options={rootFolderOptions}
|
||||||
|
onSelect={handleFolderChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
cardWidth={280}
|
||||||
|
/>
|
||||||
|
<TVOptionSelector
|
||||||
|
visible={activeSelector === "user"}
|
||||||
|
title={t("jellyseerr.request_as")}
|
||||||
|
options={userOptions}
|
||||||
|
onSelect={handleUserChange}
|
||||||
|
onClose={() => setActiveSelector(null)}
|
||||||
|
cancelLabel={t("jellyseerr.cancel")}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
maxHeight: 320,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
height: 200,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
446
app/(auth)/tv-season-select-modal.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { orderBy } from "lodash";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVButton } from "@/components/tv";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
|
import { tvSeasonSelectModalAtom } from "@/utils/atoms/tvSeasonSelectModal";
|
||||||
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
interface TVSeasonToggleCardProps {
|
||||||
|
season: {
|
||||||
|
id: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeCount: number;
|
||||||
|
status: MediaStatus;
|
||||||
|
};
|
||||||
|
selected: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
canRequest: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||||
|
season,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
canRequest,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
// Get status icon and color based on MediaStatus
|
||||||
|
const getStatusIcon = (): {
|
||||||
|
icon: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||||
|
color: string;
|
||||||
|
} | null => {
|
||||||
|
switch (season.status) {
|
||||||
|
case MediaStatus.PROCESSING:
|
||||||
|
return { icon: "clock", color: "#6366f1" };
|
||||||
|
case MediaStatus.AVAILABLE:
|
||||||
|
return { icon: "check", color: "#22c55e" };
|
||||||
|
case MediaStatus.PENDING:
|
||||||
|
return { icon: "bell", color: "#eab308" };
|
||||||
|
case MediaStatus.PARTIALLY_AVAILABLE:
|
||||||
|
return { icon: "minus", color: "#22c55e" };
|
||||||
|
case MediaStatus.BLACKLISTED:
|
||||||
|
return { icon: "eye-off", color: "#ef4444" };
|
||||||
|
default:
|
||||||
|
return canRequest ? { icon: "plus", color: "#22c55e" } : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusInfo = getStatusIcon();
|
||||||
|
const isDisabled = !canRequest;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={canRequest ? onToggle : undefined}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
styles.seasonCard,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#FFFFFF"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderWidth: focused ? 0 : 1,
|
||||||
|
borderColor: selected
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Checkmark for selected */}
|
||||||
|
<View style={styles.checkmarkContainer}>
|
||||||
|
{selected && (
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-circle'
|
||||||
|
size={24}
|
||||||
|
color={focused ? "#22c55e" : "#FFFFFF"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Season info */}
|
||||||
|
<View style={styles.seasonInfo}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.seasonTitle,
|
||||||
|
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.season_number", {
|
||||||
|
season_number: season.seasonNumber,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.episodeRow}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.episodeCount,
|
||||||
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.number_episodes", {
|
||||||
|
episode_number: season.episodeCount,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{statusInfo && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: statusInfo.color },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={statusInfo.icon}
|
||||||
|
size={14}
|
||||||
|
color='#FFFFFF'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TVSeasonSelectModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { requestMedia } = useJellyseerr();
|
||||||
|
const { showRequestModal } = useTVRequestModal();
|
||||||
|
|
||||||
|
// Selected seasons - initially select all requestable (UNKNOWN status) seasons
|
||||||
|
const [selectedSeasons, setSelectedSeasons] = useState<Set<number>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
// Initialize selected seasons when modal state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (modalState?.seasons) {
|
||||||
|
const requestableSeasons = modalState.seasons
|
||||||
|
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||||
|
.map((s) => s.seasonNumber);
|
||||||
|
setSelectedSeasons(new Set(requestableSeasons));
|
||||||
|
}
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
store.set(tvSeasonSelectModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Sort seasons by season number (ascending)
|
||||||
|
const sortedSeasons = useMemo(() => {
|
||||||
|
if (!modalState?.seasons) return [];
|
||||||
|
return orderBy(
|
||||||
|
modalState.seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
"seasonNumber",
|
||||||
|
"asc",
|
||||||
|
);
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Find the index of the first requestable season for initial focus
|
||||||
|
const firstRequestableIndex = useMemo(() => {
|
||||||
|
return sortedSeasons.findIndex((s) => s.status === MediaStatus.UNKNOWN);
|
||||||
|
}, [sortedSeasons]);
|
||||||
|
|
||||||
|
const handleToggleSeason = useCallback((seasonNumber: number) => {
|
||||||
|
setSelectedSeasons((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(seasonNumber)) {
|
||||||
|
newSet.delete(seasonNumber);
|
||||||
|
} else {
|
||||||
|
newSet.add(seasonNumber);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRequestSelected = useCallback(() => {
|
||||||
|
if (!modalState || selectedSeasons.size === 0) return;
|
||||||
|
|
||||||
|
const seasonsArray = Array.from(selectedSeasons);
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: modalState.mediaId,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: modalState.tvdbId,
|
||||||
|
seasons: seasonsArray,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modalState.hasAdvancedRequestPermission) {
|
||||||
|
// Close this modal and open the advanced request modal
|
||||||
|
router.back();
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: modalState.title,
|
||||||
|
id: modalState.mediaId,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
onRequested: modalState.onRequested,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the title based on selected seasons
|
||||||
|
const seasonTitle =
|
||||||
|
seasonsArray.length === 1
|
||||||
|
? t("jellyseerr.season_number", { season_number: seasonsArray[0] })
|
||||||
|
: seasonsArray.length === sortedSeasons.length
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: t("jellyseerr.n_selected", { count: seasonsArray.length });
|
||||||
|
|
||||||
|
requestMedia(`${modalState.title}, ${seasonTitle}`, body, () => {
|
||||||
|
modalState.onRequested();
|
||||||
|
router.back();
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
modalState,
|
||||||
|
selectedSeasons,
|
||||||
|
sortedSeasons.length,
|
||||||
|
requestMedia,
|
||||||
|
router,
|
||||||
|
t,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
|
{t("jellyseerr.select_seasons")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Season cards horizontal scroll */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{sortedSeasons.map((season, index) => {
|
||||||
|
const canRequestSeason = season.status === MediaStatus.UNKNOWN;
|
||||||
|
return (
|
||||||
|
<TVSeasonToggleCard
|
||||||
|
key={season.id}
|
||||||
|
season={season}
|
||||||
|
selected={selectedSeasons.has(season.seasonNumber)}
|
||||||
|
onToggle={() => handleToggleSeason(season.seasonNumber)}
|
||||||
|
canRequest={canRequestSeason}
|
||||||
|
hasTVPreferredFocus={index === firstRequestableIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Request button */}
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequestSelected}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={selectedSeasons.size === 0}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[styles.buttonText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_selected")}
|
||||||
|
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
seasonCard: {
|
||||||
|
width: 160,
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
|
checkmarkContainer: {
|
||||||
|
height: 24,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
seasonInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
seasonTitle: {
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
episodeRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
episodeCount: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 11,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
});
|
||||||
190
app/(auth)/tv-series-season-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
export default function TVSeriesSeasonModalPage() {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
const initialSelectedIndex = useMemo(() => {
|
||||||
|
if (!modalState?.seasons) return 0;
|
||||||
|
const idx = modalState.seasons.findIndex((o) => o.selected);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [modalState?.seasons]);
|
||||||
|
|
||||||
|
// Animate in on mount
|
||||||
|
useEffect(() => {
|
||||||
|
overlayOpacity.setValue(0);
|
||||||
|
sheetTranslateY.setValue(200);
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(overlayOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 250,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(sheetTranslateY, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
store.set(tvSeriesSeasonModalAtom, null);
|
||||||
|
};
|
||||||
|
}, [overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Focus on the selected card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
const handleSelect = (seasonIndex: number) => {
|
||||||
|
if (modalState?.onSeasonSelect) {
|
||||||
|
modalState.onSeasonSelect(seasonIndex);
|
||||||
|
}
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!modalState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.sheetContainer,
|
||||||
|
{ transform: [{ translateY: sheetTranslateY }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={styles.content}
|
||||||
|
>
|
||||||
|
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{modalState.seasons.map((season, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={season.value}
|
||||||
|
ref={
|
||||||
|
index === initialSelectedIndex ? firstCardRef : undefined
|
||||||
|
}
|
||||||
|
label={season.label}
|
||||||
|
selected={season.selected}
|
||||||
|
hasTVPreferredFocus={index === initialSelectedIndex}
|
||||||
|
onPress={() => handleSelect(season.value)}
|
||||||
|
width={180}
|
||||||
|
height={85}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.cancelButtonContainer}>
|
||||||
|
<TVCancelButton
|
||||||
|
onPress={handleCancel}
|
||||||
|
label={t("common.cancel")}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cancelButtonContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
1274
app/(auth)/tv-subtitle-modal.tsx
Normal 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,46 @@ 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.Screen
|
||||||
|
name='(auth)/tv-series-season-modal'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: "transparentModal",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Toaster
|
<Toaster
|
||||||
duration={4000}
|
duration={4000}
|
||||||
@@ -443,7 +484,7 @@ function Layout() {
|
|||||||
}}
|
}}
|
||||||
closeButton
|
closeButton
|
||||||
/>
|
/>
|
||||||
<GlobalModal />
|
{!Platform.isTV && <GlobalModal />}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</IntroSheetProvider>
|
</IntroSheetProvider>
|
||||||
</BottomSheetModalProvider>
|
</BottomSheetModalProvider>
|
||||||
|
|||||||
662
app/login.tsx
@@ -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(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerTitle: serverName,
|
|
||||||
headerLeft: () =>
|
|
||||||
api?.basePath ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
removeServer();
|
|
||||||
}}
|
|
||||||
className='flex flex-row items-center pr-2 pl-1'
|
|
||||||
>
|
|
||||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
|
||||||
<Text className=' ml-1 text-purple-600'>
|
|
||||||
{t("login.change_server")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : null,
|
|
||||||
});
|
|
||||||
}, [serverName, navigation, api?.basePath]);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
|
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
|
||||||
if (!result.success) return;
|
|
||||||
|
|
||||||
if (saveAccount) {
|
|
||||||
// Show save account modal to choose security type
|
|
||||||
setPendingLogin({
|
|
||||||
username: credentials.username,
|
|
||||||
password: credentials.password,
|
|
||||||
});
|
|
||||||
setShowSaveModal(true);
|
|
||||||
} else {
|
|
||||||
// Login without saving
|
|
||||||
await performLogin(credentials.username, credentials.password);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const performLogin = async (
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
options?: {
|
|
||||||
saveAccount?: boolean;
|
|
||||||
securityType?: AccountSecurityType;
|
|
||||||
pinCode?: string;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
await login(username, password, serverName, options);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
Alert.alert(t("login.connection_failed"), error.message);
|
|
||||||
} else {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.an_unexpected_error_occured"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setPendingLogin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAccountConfirm = async (
|
|
||||||
securityType: AccountSecurityType,
|
|
||||||
pinCode?: string,
|
|
||||||
) => {
|
|
||||||
setShowSaveModal(false);
|
|
||||||
if (pendingLogin) {
|
|
||||||
await performLogin(pendingLogin.username, pendingLogin.password, {
|
|
||||||
saveAccount: true,
|
|
||||||
securityType,
|
|
||||||
pinCode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuickLoginWithSavedCredential = async (
|
|
||||||
serverUrl: string,
|
|
||||||
userId: string,
|
|
||||||
) => {
|
|
||||||
await loginWithSavedCredential(serverUrl, userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordLogin = async (
|
|
||||||
serverUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
) => {
|
|
||||||
await loginWithPassword(serverUrl, username, password);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddAccount = (server: SavedServer) => {
|
|
||||||
// Server is already selected, go to credential entry
|
|
||||||
setServer({ address: server.address });
|
|
||||||
if (server.name) {
|
|
||||||
setServerName(server.name);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
|
||||||
*
|
|
||||||
* This function attempts to connect to a Jellyfin server using the provided URL.
|
|
||||||
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
|
|
||||||
*
|
|
||||||
* @param {string} url - The base URL of the Jellyfin server to check.
|
|
||||||
* @returns {Promise<string | undefined>} A Promise that resolves to:
|
|
||||||
* - The full URL (including protocol) if a valid Jellyfin server is found.
|
|
||||||
* - undefined if no valid server is found at the given URL.
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
|
||||||
* - Logs errors and timeout information to the console.
|
|
||||||
*/
|
|
||||||
const checkUrl = useCallback(async (url: string) => {
|
|
||||||
setLoadingServerCheck(true);
|
|
||||||
const baseUrl = url.replace(/^https?:\/\//i, "");
|
|
||||||
const protocols = ["https", "http"];
|
|
||||||
try {
|
|
||||||
return checkHttp(baseUrl, protocols);
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Server too old") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
setLoadingServerCheck(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function checkHttp(baseUrl: string, protocols: string[]) {
|
|
||||||
for (const protocol of protocols) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${protocol}://${baseUrl}/System/Info/Public`,
|
|
||||||
{
|
|
||||||
mode: "cors",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
|
||||||
const serverVersion = data.Version?.split(".");
|
|
||||||
if (serverVersion && +serverVersion[0] <= 10) {
|
|
||||||
if (+serverVersion[1] < 10) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.too_old_server_text"),
|
|
||||||
t("login.too_old_server_description"),
|
|
||||||
);
|
|
||||||
throw new Error("Server too old");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setServerName(data.ServerName || "");
|
|
||||||
return `${protocol}://${baseUrl}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message === "Server too old") {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
|
||||||
*
|
|
||||||
* This function trims the input URL, checks its validity using the `checkUrl` function,
|
|
||||||
* and sets the server address if a valid connection is established.
|
|
||||||
*
|
|
||||||
* @param {string} url - The URL of the Jellyfin server to connect to.
|
|
||||||
*
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*
|
|
||||||
* Side effects:
|
|
||||||
* - Calls `checkUrl` to validate the server URL.
|
|
||||||
* - Shows an alert if the connection fails.
|
|
||||||
* - Sets the server address using `setServer` if the connection is successful.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const handleConnect = useCallback(async (url: string) => {
|
|
||||||
url = url.trim().replace(/\/$/, "");
|
|
||||||
try {
|
|
||||||
const result = await checkUrl(url);
|
|
||||||
if (result === undefined) {
|
|
||||||
Alert.alert(
|
|
||||||
t("login.connection_failed"),
|
|
||||||
t("login.could_not_connect_to_server"),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setServer({ address: result });
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
return <Login />;
|
||||||
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 doesn’t 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;
|
export default LoginPage;
|
||||||
|
|||||||
BIN
assets/images/icon-tvos-small-2x.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
assets/images/icon-tvos-small.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/images/icon-tvos-topshelf-2x.png
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
assets/images/icon-tvos-topshelf-wide-2x.png
Normal file
|
After Width: | Height: | Size: 417 KiB |
BIN
assets/images/icon-tvos-topshelf-wide.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
assets/images/icon-tvos-topshelf.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/images/icon-tvos.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
9
bun.lock
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -57,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",
|
||||||
@@ -539,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=="],
|
||||||
@@ -559,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=="],
|
||||||
@@ -1643,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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -14,6 +16,8 @@ export const Badge: React.FC<Props> = ({
|
|||||||
variant = "purple",
|
variant = "purple",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||||
@@ -28,7 +32,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 +42,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: typography.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>
|
||||||
|
|||||||
@@ -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
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import type { PlaybackProgressInfo } from "@jellyfin/sdk/lib/generated-client/models";
|
import { useCallback, useEffect } from "react";
|
||||||
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { Pressable } from "react-native-gesture-handler";
|
import { Pressable } from "react-native-gesture-handler";
|
||||||
import GoogleCast, {
|
import GoogleCast, {
|
||||||
@@ -14,7 +10,6 @@ import GoogleCast, {
|
|||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
} from "react-native-google-cast";
|
} from "react-native-google-cast";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
|
|
||||||
export function Chromecast({
|
export function Chromecast({
|
||||||
@@ -23,123 +18,23 @@ export function Chromecast({
|
|||||||
background = "transparent",
|
background = "transparent",
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const _client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const _castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
const _sessionManager = GoogleCast.getSessionManager();
|
const sessionManager = GoogleCast.getSessionManager();
|
||||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||||
const mediaStatus = useMediaStatus();
|
const mediaStatus = useMediaStatus();
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const lastReportedProgressRef = useRef(0);
|
|
||||||
const discoveryAttempts = useRef(0);
|
|
||||||
const maxDiscoveryAttempts = 3;
|
|
||||||
const hasLoggedDevices = useRef(false);
|
|
||||||
|
|
||||||
// Enhanced discovery with retry mechanism - runs once on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isSubscribed = true;
|
(async () => {
|
||||||
let retryTimeout: NodeJS.Timeout;
|
|
||||||
|
|
||||||
const startDiscoveryWithRetry = async () => {
|
|
||||||
if (!discoveryManager) {
|
if (!discoveryManager) {
|
||||||
|
console.warn("DiscoveryManager is not initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await discoveryManager.startDiscovery();
|
||||||
// Stop any existing discovery first
|
})();
|
||||||
try {
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
await discoveryManager.stopDiscovery();
|
|
||||||
} catch (_e) {
|
|
||||||
// Ignore errors when stopping
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start fresh discovery
|
|
||||||
await discoveryManager.startDiscovery();
|
|
||||||
discoveryAttempts.current = 0; // Reset on success
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Chromecast Discovery] Failed:", error);
|
|
||||||
|
|
||||||
// Retry on error
|
|
||||||
if (discoveryAttempts.current < maxDiscoveryAttempts && isSubscribed) {
|
|
||||||
discoveryAttempts.current++;
|
|
||||||
retryTimeout = setTimeout(() => {
|
|
||||||
if (isSubscribed) {
|
|
||||||
startDiscoveryWithRetry();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
startDiscoveryWithRetry();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isSubscribed = false;
|
|
||||||
if (retryTimeout) {
|
|
||||||
clearTimeout(retryTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [discoveryManager]); // Only re-run if discoveryManager changes
|
|
||||||
|
|
||||||
// Log device changes for debugging - only once per session
|
|
||||||
useEffect(() => {
|
|
||||||
if (devices.length > 0 && !hasLoggedDevices.current) {
|
|
||||||
console.log(
|
|
||||||
"[Chromecast] Found device(s):",
|
|
||||||
devices.map((d) => d.friendlyName || d.deviceId).join(", "),
|
|
||||||
);
|
|
||||||
hasLoggedDevices.current = true;
|
|
||||||
}
|
|
||||||
}, [devices]);
|
|
||||||
|
|
||||||
// Report video progress to Jellyfin server
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!api ||
|
|
||||||
!user?.Id ||
|
|
||||||
!mediaStatus ||
|
|
||||||
!mediaStatus.mediaInfo?.contentId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamPosition = mediaStatus.streamPosition || 0;
|
|
||||||
|
|
||||||
// Report every 10 seconds
|
|
||||||
if (Math.abs(streamPosition - lastReportedProgressRef.current) < 10) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentId = mediaStatus.mediaInfo.contentId;
|
|
||||||
const positionTicks = Math.floor(streamPosition * 10000000);
|
|
||||||
const isPaused = mediaStatus.playerState === "paused";
|
|
||||||
const streamUrl = mediaStatus.mediaInfo.contentUrl || "";
|
|
||||||
const isTranscoding = streamUrl.includes("m3u8");
|
|
||||||
|
|
||||||
const progressInfo: PlaybackProgressInfo = {
|
|
||||||
ItemId: contentId,
|
|
||||||
PositionTicks: positionTicks,
|
|
||||||
IsPaused: isPaused,
|
|
||||||
PlayMethod: isTranscoding ? "Transcode" : "DirectStream",
|
|
||||||
PlaySessionId: contentId,
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlaystateApi(api)
|
|
||||||
.reportPlaybackProgress({ playbackProgressInfo: progressInfo })
|
|
||||||
.then(() => {
|
|
||||||
lastReportedProgressRef.current = streamPosition;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to report Chromecast progress:", error);
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
mediaStatus?.streamPosition,
|
|
||||||
mediaStatus?.mediaInfo?.contentId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Android requires the cast button to be present for startDiscovery to work
|
// Android requires the cast button to be present for startDiscovery to work
|
||||||
const AndroidCastButton = useCallback(
|
const AndroidCastButton = useCallback(
|
||||||
@@ -153,11 +48,8 @@ export function Chromecast({
|
|||||||
<Pressable
|
<Pressable
|
||||||
className='mr-4'
|
className='mr-4'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
router.push("/casting-player");
|
else CastContext.showCastDialog();
|
||||||
} else {
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -174,11 +66,8 @@ export function Chromecast({
|
|||||||
className='mr-2'
|
className='mr-2'
|
||||||
background={false}
|
background={false}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
router.replace("/casting-player" as any);
|
else CastContext.showCastDialog();
|
||||||
} else {
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -191,11 +80,8 @@ export function Chromecast({
|
|||||||
<RoundButton
|
<RoundButton
|
||||||
size='large'
|
size='large'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (mediaStatus?.currentItemId) {
|
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||||
router.push("/casting-player");
|
else CastContext.showCastDialog();
|
||||||
} else {
|
|
||||||
CastContext.showCastDialog();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|||||||
188
components/ContinueWatchingPoster.tv.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
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 {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ProgressBar } from "./common/ProgressBar";
|
||||||
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
|
export const TV_LANDSCAPE_WIDTH = 400;
|
||||||
|
|
||||||
|
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=700&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie") {
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}, [api, item, useEpisodePoster]);
|
||||||
|
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
if (!item.StartDate || !item.EndDate) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const startDate = new Date(item.StartDate);
|
||||||
|
const endDate = new Date(item.EndDate);
|
||||||
|
const now = new Date();
|
||||||
|
const total = endDate.getTime() - startDate.getTime();
|
||||||
|
if (total <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const elapsed = now.getTime() - startDate.getTime();
|
||||||
|
return (elapsed / total) * 100;
|
||||||
|
}
|
||||||
|
return item.UserData?.PlayedPercentage || 0;
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
const isWatched = item.UserData?.Played === true;
|
||||||
|
|
||||||
|
// Use glass effect on tvOS 26+
|
||||||
|
const useGlass = isGlassEffectAvailable();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_LANDSCAPE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<View style={{ position: "relative" }}>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={url}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={isWatched}
|
||||||
|
isFocused={false}
|
||||||
|
width={TV_LANDSCAPE_WIDTH}
|
||||||
|
style={{ width: TV_LANDSCAPE_WIDTH }}
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older tvOS versions
|
||||||
|
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;
|
||||||
@@ -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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -23,7 +25,10 @@ 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") {
|
// Hook must be called at the top level, before any conditional returns
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<GlassEffectView style={styles.glass}>
|
<GlassEffectView style={styles.glass}>
|
||||||
@@ -40,6 +45,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: typography.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 +97,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) => (
|
||||||
|
|||||||
@@ -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,229 +47,243 @@ 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> = ({
|
||||||
const [api] = useAtom(apiAtom);
|
item,
|
||||||
const isOffline = useOfflineMode();
|
itemWithSources,
|
||||||
const { settings } = useSettings();
|
}) => {
|
||||||
const { orientation } = useOrientation();
|
const [api] = useAtom(apiAtom);
|
||||||
const navigation = useNavigation();
|
const isOffline = useOfflineMode();
|
||||||
const insets = useSafeAreaInsets();
|
const { settings } = useSettings();
|
||||||
const [user] = useAtom(userAtom);
|
const { orientation } = useOrientation();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const itemColors = useImageColorsReturn({ item });
|
const itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
const [loadingLogo, setLoadingLogo] = useState(true);
|
const [loadingLogo, setLoadingLogo] = useState(true);
|
||||||
const [headerHeight, setHeaderHeight] = useState(350);
|
const [headerHeight, setHeaderHeight] = useState(350);
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
// Use itemWithSources for play settings since it has MediaSources data
|
// Use itemWithSources for play settings since it has MediaSources data
|
||||||
const {
|
const {
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
[api, item],
|
[api, item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLogoLoad = React.useCallback(() => {
|
const onLogoLoad = React.useCallback(() => {
|
||||||
setLoadingLogo(false);
|
setLoadingLogo(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return Boolean(logoUrl && loadingLogo);
|
return Boolean(logoUrl && loadingLogo);
|
||||||
}, [loadingLogo, logoUrl]);
|
}, [loadingLogo, logoUrl]);
|
||||||
|
|
||||||
// Needs to automatically change the selected to the default values for default indexes.
|
// Needs to automatically change the selected to the default values for default indexes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedOptions(() => ({
|
setSelectedOptions(() => ({
|
||||||
bitrate: defaultBitrate,
|
bitrate: defaultBitrate,
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
audioIndex: defaultAudioIndex,
|
audioIndex: defaultAudioIndex,
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
defaultAudioIndex,
|
defaultAudioIndex,
|
||||||
defaultBitrate,
|
defaultBitrate,
|
||||||
defaultSubtitleIndex,
|
defaultSubtitleIndex,
|
||||||
defaultMediaSource,
|
defaultMediaSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Platform.isTV && itemWithSources) {
|
if (!Platform.isTV && itemWithSources) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () =>
|
headerRight: () =>
|
||||||
item &&
|
item &&
|
||||||
(Platform.OS === "ios" ? (
|
(Platform.OS === "ios" ? (
|
||||||
<View className='flex flex-row items-center pl-2'>
|
<View className='flex flex-row items-center pl-2'>
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
<Chromecast.Chromecast width={22} height={22} />
|
||||||
{item.Type !== "Program" && (
|
{item.Type !== "Program" && (
|
||||||
<View className='flex flex-row items-center'>
|
<View className='flex flex-row items-center'>
|
||||||
{!Platform.isTV && (
|
{!Platform.isTV && (
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
|
)}
|
||||||
|
{user?.Policy?.IsAdministrator &&
|
||||||
|
!settings.hideRemoteSessionButton && (
|
||||||
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator &&
|
|
||||||
!settings.hideRemoteSessionButton && (
|
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<AddToFavorites item={item} />
|
<AddToFavorites item={item} />
|
||||||
{settings.streamyStatsServerUrl &&
|
{settings.streamyStatsServerUrl &&
|
||||||
!settings.hideWatchlistsTab && (
|
!settings.hideWatchlistsTab && (
|
||||||
<AddToWatchlist item={item} />
|
<AddToWatchlist item={item} />
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
|
||||||
<Chromecast.Chromecast width={22} height={22} />
|
|
||||||
{item.Type !== "Program" && (
|
|
||||||
<View className='flex flex-row items-center space-x-2'>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
|
||||||
)}
|
)}
|
||||||
{user?.Policy?.IsAdministrator &&
|
</View>
|
||||||
!settings.hideRemoteSessionButton && (
|
)}
|
||||||
<PlayInRemoteSessionButton item={item} size='large' />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<PlayedStatus items={[item]} size='large' />
|
|
||||||
<AddToFavorites item={item} />
|
|
||||||
{settings.streamyStatsServerUrl &&
|
|
||||||
!settings.hideWatchlistsTab && (
|
|
||||||
<AddToWatchlist item={item} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
item,
|
|
||||||
navigation,
|
|
||||||
user,
|
|
||||||
itemWithSources,
|
|
||||||
settings.hideRemoteSessionButton,
|
|
||||||
settings.streamyStatsServerUrl,
|
|
||||||
settings.hideWatchlistsTab,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
|
||||||
setHeaderHeight(230);
|
|
||||||
else if (item.Type === "Movie") setHeaderHeight(500);
|
|
||||||
else setHeaderHeight(350);
|
|
||||||
}
|
|
||||||
}, [item, orientation]);
|
|
||||||
|
|
||||||
if (!item || !selectedOptions) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className='flex-1 relative'
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParallaxScrollView
|
|
||||||
className='flex-1'
|
|
||||||
headerHeight={headerHeight}
|
|
||||||
headerImage={
|
|
||||||
<View style={[{ flex: 1 }]}>
|
|
||||||
<ItemImage
|
|
||||||
variant={
|
|
||||||
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
|
||||||
}
|
|
||||||
item={item}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
) : (
|
||||||
logo={
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
logoUrl ? (
|
<Chromecast.Chromecast width={22} height={22} />
|
||||||
<Image
|
{item.Type !== "Program" && (
|
||||||
source={{
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
uri: logoUrl,
|
{!Platform.isTV && (
|
||||||
}}
|
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||||
style={{
|
)}
|
||||||
height: 130,
|
{user?.Policy?.IsAdministrator &&
|
||||||
width: "100%",
|
!settings.hideRemoteSessionButton && (
|
||||||
}}
|
<PlayInRemoteSessionButton item={item} size='large' />
|
||||||
contentFit='contain'
|
)}
|
||||||
onLoad={onLogoLoad}
|
|
||||||
onError={onLogoLoad}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col bg-transparent shrink'>
|
|
||||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
|
||||||
<ItemHeader item={item} className='mb-2' />
|
|
||||||
|
|
||||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
<PlayedStatus items={[item]} size='large' />
|
||||||
<PlayButton
|
<AddToFavorites item={item} />
|
||||||
|
{settings.streamyStatsServerUrl &&
|
||||||
|
!settings.hideWatchlistsTab && (
|
||||||
|
<AddToWatchlist item={item} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
item,
|
||||||
|
navigation,
|
||||||
|
user,
|
||||||
|
itemWithSources,
|
||||||
|
settings.hideRemoteSessionButton,
|
||||||
|
settings.streamyStatsServerUrl,
|
||||||
|
settings.hideWatchlistsTab,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||||
|
setHeaderHeight(230);
|
||||||
|
else if (item.Type === "Movie") setHeaderHeight(500);
|
||||||
|
else setHeaderHeight(350);
|
||||||
|
}
|
||||||
|
}, [item, orientation]);
|
||||||
|
|
||||||
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 relative'
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className='flex-1'
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
headerImage={
|
||||||
|
<View style={[{ flex: 1 }]}>
|
||||||
|
<ItemImage
|
||||||
|
variant={
|
||||||
|
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
|
||||||
|
}
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
logo={
|
||||||
|
logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: logoUrl,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
height: 130,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
onLoad={onLogoLoad}
|
||||||
|
onError={onLogoLoad}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col bg-transparent shrink'>
|
||||||
|
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||||
|
<ItemHeader item={item} className='mb-2' />
|
||||||
|
|
||||||
|
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||||
|
<PlayButton
|
||||||
|
selectedOptions={selectedOptions}
|
||||||
|
item={item}
|
||||||
|
colors={itemColors}
|
||||||
|
/>
|
||||||
|
<View className='w-1' />
|
||||||
|
{!isOffline && (
|
||||||
|
<MediaSourceButton
|
||||||
selectedOptions={selectedOptions}
|
selectedOptions={selectedOptions}
|
||||||
item={item}
|
setSelectedOptions={setSelectedOptions}
|
||||||
|
item={itemWithSources}
|
||||||
colors={itemColors}
|
colors={itemColors}
|
||||||
/>
|
/>
|
||||||
<View className='w-1' />
|
)}
|
||||||
{!isOffline && (
|
|
||||||
<MediaSourceButton
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
setSelectedOptions={setSelectedOptions}
|
|
||||||
item={itemWithSources}
|
|
||||||
colors={itemColors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{item.Type === "Episode" && (
|
</View>
|
||||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
{item.Type === "Episode" && (
|
||||||
|
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOffline &&
|
||||||
|
selectedOptions.mediaSource?.MediaStreams &&
|
||||||
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOffline &&
|
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||||
selectedOptions.mediaSource?.MediaStreams &&
|
|
||||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
{item.Type !== "Program" && (
|
||||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
<>
|
||||||
|
{item.Type === "Episode" && !isOffline && (
|
||||||
|
<CurrentSeries item={item} className='mb-2' />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
<ItemPeopleSections item={item} />
|
||||||
|
|
||||||
{item.Type !== "Program" && (
|
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||||
<>
|
</>
|
||||||
{item.Type === "Episode" && !isOffline && (
|
)}
|
||||||
<CurrentSeries item={item} className='mb-2' />
|
</View>
|
||||||
)}
|
</ParallaxScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
<ItemPeopleSections item={item} />
|
// Memoize the mobile component
|
||||||
|
const MemoizedItemContentMobile = React.memo(ItemContentMobile);
|
||||||
|
|
||||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
// Exported component that renders TV or mobile version based on platform
|
||||||
</>
|
export const ItemContent: React.FC<ItemContentProps> = (props) => {
|
||||||
)}
|
if (Platform.isTV && ItemContentTV) {
|
||||||
</View>
|
return <ItemContentTV {...props} />;
|
||||||
</ParallaxScrollView>
|
}
|
||||||
</View>
|
return <MemoizedItemContentMobile {...props} />;
|
||||||
);
|
};
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
873
components/ItemContent.tv.tsx
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
MediaStream,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, ScrollView, 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 { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
||||||
|
import {
|
||||||
|
TVBackdrop,
|
||||||
|
TVButton,
|
||||||
|
TVCastCrewText,
|
||||||
|
TVCastSection,
|
||||||
|
TVFavoriteButton,
|
||||||
|
TVMetadataBadges,
|
||||||
|
TVOptionButton,
|
||||||
|
TVProgressBar,
|
||||||
|
TVRefreshButton,
|
||||||
|
TVSeriesNavigation,
|
||||||
|
TVTechnicalDetails,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
|
import { useScaledTVTypography } 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 typography = useScaledTVTypography();
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// State for first episode card ref (used for focus guide)
|
||||||
|
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
// Fetch season episodes for episodes
|
||||||
|
const { data: seasonEpisodes = [] } = useQuery({
|
||||||
|
queryKey: ["episodes", item?.SeasonId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.SeriesId,
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: item.SeasonId,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "Overview"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
!!api &&
|
||||||
|
!!user?.Id &&
|
||||||
|
!!item?.SeriesId &&
|
||||||
|
!!item?.SeasonId &&
|
||||||
|
item?.Type === "Episode",
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (raw MediaStream[] for label lookup)
|
||||||
|
const subtitleStreams = useMemo(() => {
|
||||||
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
return streams ?? [];
|
||||||
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
|
// Store handleSubtitleChange in a ref for stable callback reference
|
||||||
|
const handleSubtitleChangeRef = useRef<((index: number) => void) | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert MediaStream[] to Track[] for the modal (with setTrack callbacks)
|
||||||
|
const subtitleTracksForModal = useMemo((): Track[] => {
|
||||||
|
return subtitleStreams.map((stream) => ({
|
||||||
|
name:
|
||||||
|
stream.DisplayTitle ||
|
||||||
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
|
index: stream.Index ?? -1,
|
||||||
|
setTrack: () => {
|
||||||
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [subtitleStreams]);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the ref updated with the latest callback
|
||||||
|
handleSubtitleChangeRef.current = handleSubtitleChange;
|
||||||
|
|
||||||
|
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<Track[]> => {
|
||||||
|
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];
|
||||||
|
|
||||||
|
// Get subtitle streams from the fresh data
|
||||||
|
const streams =
|
||||||
|
mediaSource?.MediaStreams?.filter(
|
||||||
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
// Convert to Track[] with setTrack callbacks
|
||||||
|
return streams.map((stream) => ({
|
||||||
|
name:
|
||||||
|
stream.DisplayTitle ||
|
||||||
|
`${stream.Language || "Unknown"} (${stream.Codec})`,
|
||||||
|
index: stream.Index ?? -1,
|
||||||
|
setTrack: () => {
|
||||||
|
handleSubtitleChangeRef.current?.(stream.Index ?? -1);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} 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 = subtitleStreams.find(
|
||||||
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||||
|
);
|
||||||
|
}, [subtitleStreams, 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]);
|
||||||
|
|
||||||
|
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
|
||||||
|
const episodeThumbnailUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode" || !api) return null;
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
|
||||||
|
const seriesThumbUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
|
||||||
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
// Determine which option button is the last one (for focus guide targeting)
|
||||||
|
const lastOptionButton = useMemo(() => {
|
||||||
|
const hasSubtitleOption =
|
||||||
|
subtitleStreams.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";
|
||||||
|
}, [
|
||||||
|
subtitleStreams.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]);
|
||||||
|
|
||||||
|
const handleEpisodePress = useCallback(
|
||||||
|
(episode: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(episode, "(home)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 - 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: typography.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: typography.title,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.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: typography.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: typography.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 */}
|
||||||
|
{(subtitleStreams.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: subtitleTracksForModal,
|
||||||
|
currentSubtitleIndex:
|
||||||
|
selectedOptions?.subtitleIndex ?? -1,
|
||||||
|
onDisableSubtitles: () => handleSubtitleChange(-1),
|
||||||
|
onServerSubtitleDownloaded:
|
||||||
|
handleServerSubtitleDownloaded,
|
||||||
|
refreshSubtitleTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress bar (if partially watched) */}
|
||||||
|
{hasProgress && item.RunTimeTicks != null && (
|
||||||
|
<TVProgressBar
|
||||||
|
progress={
|
||||||
|
(item.UserData?.PlaybackPositionTicks || 0) /
|
||||||
|
item.RunTimeTicks
|
||||||
|
}
|
||||||
|
fillColor='#FFFFFF'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Poster */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
item.Type === "Episode"
|
||||||
|
? SCREEN_WIDTH * 0.35
|
||||||
|
: SCREEN_WIDTH * 0.22,
|
||||||
|
marginLeft: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri:
|
||||||
|
settings.showSeriesPosterOnEpisode && seriesThumbUrl
|
||||||
|
? seriesThumbUrl
|
||||||
|
: episodeThumbnailUrl!,
|
||||||
|
}}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemImage
|
||||||
|
variant='Primary'
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Additional info section */}
|
||||||
|
<View style={{ marginTop: 40 }}>
|
||||||
|
{/* Season Episodes - Episode only */}
|
||||||
|
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
|
||||||
|
<View style={{ marginBottom: 40 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.more_from_this_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 80,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasonEpisodes.map((episode, index) => (
|
||||||
|
<TVEpisodeCard
|
||||||
|
key={episode.Id}
|
||||||
|
episode={episode}
|
||||||
|
onPress={() => handleEpisodePress(episode)}
|
||||||
|
disabled={episode.Id === item.Id}
|
||||||
|
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* From this Series - Episode only */}
|
||||||
|
<TVSeriesNavigation
|
||||||
|
item={item}
|
||||||
|
seriesImageUrl={seriesImageUrl}
|
||||||
|
seasonImageUrl={seasonImageUrl}
|
||||||
|
onSeriesPress={handleSeriesPress}
|
||||||
|
onSeasonPress={handleSeasonPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||||
|
{showVisualCast && (
|
||||||
|
<TVCastSection
|
||||||
|
cast={fullCast}
|
||||||
|
apiBasePath={api?.basePath}
|
||||||
|
onActorPress={handleActorPress}
|
||||||
|
firstActorRefSetter={setFirstActorCardRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cast & Crew (text version - director, etc.) */}
|
||||||
|
<TVCastCrewText
|
||||||
|
director={director}
|
||||||
|
cast={cast}
|
||||||
|
hideCast={showVisualCast}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Technical details */}
|
||||||
|
{selectedOptions.mediaSource?.MediaStreams &&
|
||||||
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<TVTechnicalDetails
|
||||||
|
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
|
||||||
|
export const ItemContent = ItemContentTV;
|
||||||
163
components/ItemContentSkeleton.tv.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Dimensions, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
export const ItemContentSkeletonTV: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingTop: insets.top + 140,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Content placeholders */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Logo placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 150,
|
||||||
|
width: "80%",
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Right side - Poster placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * 0.22,
|
||||||
|
marginLeft: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,15 +176,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("URL: ", data?.url, enableH265);
|
console.log("URL: ", data?.url, enableH265);
|
||||||
console.log("[PlayButton] Item before casting:", {
|
|
||||||
Type: item.Type,
|
|
||||||
Id: item.Id,
|
|
||||||
Name: item.Name,
|
|
||||||
ParentIndexNumber: item.ParentIndexNumber,
|
|
||||||
IndexNumber: item.IndexNumber,
|
|
||||||
SeasonId: item.SeasonId,
|
|
||||||
SeriesId: item.SeriesId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data?.url) {
|
if (!data?.url) {
|
||||||
console.warn("No URL returned from getStreamUrl", data);
|
console.warn("No URL returned from getStreamUrl", data);
|
||||||
@@ -204,11 +195,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
? item.RunTimeTicks / 10000000
|
? item.RunTimeTicks / 10000000
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
console.log("[PlayButton] Loading media with customData:", {
|
|
||||||
hasCustomData: !!item,
|
|
||||||
customDataType: item.Type,
|
|
||||||
});
|
|
||||||
|
|
||||||
client
|
client
|
||||||
.loadMedia({
|
.loadMedia({
|
||||||
mediaInfo: {
|
mediaInfo: {
|
||||||
@@ -217,7 +203,6 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
contentType: "video/mp4",
|
contentType: "video/mp4",
|
||||||
streamType: MediaStreamType.BUFFERED,
|
streamType: MediaStreamType.BUFFERED,
|
||||||
streamDuration: streamDurationSeconds,
|
streamDuration: streamDurationSeconds,
|
||||||
customData: item,
|
|
||||||
metadata:
|
metadata:
|
||||||
item.Type === "Episode"
|
item.Type === "Episode"
|
||||||
? {
|
? {
|
||||||
@@ -276,7 +261,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
if (isOpeningCurrentlyPlayingMedia) {
|
if (isOpeningCurrentlyPlayingMedia) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push("/casting-player");
|
CastContext.showExpandedControls();
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -1,909 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
|
||||||
type SharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { ItemImage } from "../common/ItemImage";
|
|
||||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
|
||||||
import type { SelectedOptions } from "../ItemContent";
|
|
||||||
import { PlayButton } from "../PlayButton";
|
|
||||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
|
||||||
initialIndex?: number;
|
|
||||||
onItemChange?: (index: number) => void;
|
|
||||||
scrollOffset?: SharedValue<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout Constants
|
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
|
||||||
const LOGO_HEIGHT = 80;
|
|
||||||
|
|
||||||
// Position Constants
|
|
||||||
const LOGO_BOTTOM_POSITION = 260;
|
|
||||||
const GENRES_BOTTOM_POSITION = 220;
|
|
||||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
|
||||||
const CONTROLS_BOTTOM_POSITION = 80;
|
|
||||||
const DOTS_BOTTOM_POSITION = 40;
|
|
||||||
|
|
||||||
// Size Constants
|
|
||||||
const DOT_HEIGHT = 6;
|
|
||||||
const DOT_ACTIVE_WIDTH = 20;
|
|
||||||
const DOT_INACTIVE_WIDTH = 12;
|
|
||||||
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|
||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
|
||||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
|
||||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
|
||||||
|
|
||||||
// Spacing Constants
|
|
||||||
const HORIZONTAL_PADDING = 40;
|
|
||||||
const DOT_PADDING = 2;
|
|
||||||
const DOT_GAP = 4;
|
|
||||||
const CONTROLS_GAP = 10;
|
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
|
||||||
|
|
||||||
// Border Radius Constants
|
|
||||||
const DOT_BORDER_RADIUS = 3;
|
|
||||||
const LOGO_SKELETON_BORDER_RADIUS = 8;
|
|
||||||
const TEXT_SKELETON_BORDER_RADIUS = 4;
|
|
||||||
const PLAY_BUTTON_BORDER_RADIUS = 25;
|
|
||||||
const PLAYED_STATUS_BORDER_RADIUS = 20;
|
|
||||||
|
|
||||||
// Animation Constants
|
|
||||||
const DOT_ANIMATION_DURATION = 300;
|
|
||||||
const CAROUSEL_TRANSITION_DURATION = 250;
|
|
||||||
const PAN_ACTIVE_OFFSET = 10;
|
|
||||||
const TRANSLATION_THRESHOLD = 0.2;
|
|
||||||
const VELOCITY_THRESHOLD = 400;
|
|
||||||
|
|
||||||
// Text Constants
|
|
||||||
const GENRES_FONT_SIZE = 16;
|
|
||||||
const OVERVIEW_FONT_SIZE = 14;
|
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
|
||||||
const MAX_GENRES_COUNT = 2;
|
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
|
||||||
const OVERVIEW_MAX_LINES = 2;
|
|
||||||
const OVERVIEW_MAX_WIDTH = "80%";
|
|
||||||
|
|
||||||
// Opacity Constants
|
|
||||||
const OVERLAY_OPACITY = 0.3;
|
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
|
||||||
const TEXT_OPACITY = 0.9;
|
|
||||||
|
|
||||||
// Color Constants
|
|
||||||
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
|
|
||||||
const SKELETON_ELEMENT_COLOR = "#333";
|
|
||||||
const SKELETON_ACTIVE_DOT_COLOR = "#666";
|
|
||||||
const _EMPTY_STATE_COLOR = "#666";
|
|
||||||
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
|
|
||||||
const LOGO_WIDTH_PERCENTAGE = "80%";
|
|
||||||
|
|
||||||
const DotIndicator = ({
|
|
||||||
index,
|
|
||||||
currentIndex,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
index: number;
|
|
||||||
currentIndex: number;
|
|
||||||
onPress: (index: number) => void;
|
|
||||||
}) => {
|
|
||||||
const isActive = index === currentIndex;
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onPress(index)}
|
|
||||||
style={{
|
|
||||||
padding: DOT_PADDING, // Increase touch area
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|
||||||
initialIndex = 0,
|
|
||||||
onItemChange,
|
|
||||||
scrollOffset,
|
|
||||||
}) => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
|
||||||
const router = useRouter();
|
|
||||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
||||||
const isLandscape = screenWidth >= screenHeight;
|
|
||||||
const carouselHeight = useMemo(
|
|
||||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
|
||||||
[isLandscape, screenHeight],
|
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
||||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
|
||||||
|
|
||||||
const isQueryEnabled =
|
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
|
||||||
|
|
||||||
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
|
|
||||||
{
|
|
||||||
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user.Id,
|
|
||||||
limit: 2,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
});
|
|
||||||
return response.data || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const continueItems = continueWatchingData ?? [];
|
|
||||||
const nextItems = nextUpData ?? [];
|
|
||||||
const recentItems = recentlyAddedData ?? [];
|
|
||||||
|
|
||||||
const allItems = [
|
|
||||||
...continueItems.slice(0, 2),
|
|
||||||
...nextItems.slice(0, 2),
|
|
||||||
...recentItems.slice(0, 2),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deduplicate by item ID to prevent duplicate keys
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return allItems.filter((item) => {
|
|
||||||
if (item.Id && !seen.has(item.Id)) {
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
|
|
||||||
const hasItems = items.length > 0;
|
|
||||||
|
|
||||||
// Only get play settings if we have valid items
|
|
||||||
const currentItem = hasItems ? items[currentIndex] : null;
|
|
||||||
|
|
||||||
// Extract colors for the current item only (for performance)
|
|
||||||
const currentItemColors = useImageColorsReturn({ item: currentItem });
|
|
||||||
|
|
||||||
// Create a fallback empty item for useDefaultPlaySettings when no item is available
|
|
||||||
const itemForPlaySettings = currentItem || { MediaSources: [] };
|
|
||||||
const {
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultMediaSource,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
|
||||||
SelectedOptions | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only set options if we have valid current item
|
|
||||||
if (currentItem) {
|
|
||||||
setSelectedOptions({
|
|
||||||
bitrate: defaultBitrate,
|
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedOptions(undefined);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
defaultMediaSource,
|
|
||||||
currentIndex,
|
|
||||||
currentItem,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasItems) {
|
|
||||||
setCurrentIndex(initialIndex);
|
|
||||||
translateX.value = -initialIndex * screenWidth;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex((prev) => {
|
|
||||||
const newIndex = Math.min(prev, items.length - 1);
|
|
||||||
translateX.value = -newIndex * screenWidth;
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = -currentIndex * screenWidth;
|
|
||||||
}, [currentIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasItems) {
|
|
||||||
onItemChange?.(currentIndex);
|
|
||||||
}
|
|
||||||
}, [hasItems, currentIndex, onItemChange]);
|
|
||||||
|
|
||||||
const goToIndex = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (!hasItems || index < 0 || index >= items.length) return;
|
|
||||||
|
|
||||||
translateX.value = withTiming(-index * screenWidth, {
|
|
||||||
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
|
|
||||||
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentIndex(index);
|
|
||||||
onItemChange?.(index);
|
|
||||||
},
|
|
||||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
|
||||||
(item: BaseItemDto) => {
|
|
||||||
const navigation = getItemNavigation(item, "(home)");
|
|
||||||
router.push(navigation as any);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const panGesture = Gesture.Pan()
|
|
||||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
|
||||||
.onUpdate((event) => {
|
|
||||||
translateX.value = -currentIndex * screenWidth + event.translationX;
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
const velocity = event.velocityX;
|
|
||||||
const translation = event.translationX;
|
|
||||||
|
|
||||||
let newIndex = currentIndex;
|
|
||||||
|
|
||||||
// Improved thresholds for more responsive navigation
|
|
||||||
if (
|
|
||||||
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
|
|
||||||
Math.abs(velocity) > VELOCITY_THRESHOLD
|
|
||||||
) {
|
|
||||||
if (translation > 0 && currentIndex > 0) {
|
|
||||||
newIndex = currentIndex - 1;
|
|
||||||
} else if (
|
|
||||||
translation < 0 &&
|
|
||||||
items &&
|
|
||||||
currentIndex < items.length - 1
|
|
||||||
) {
|
|
||||||
newIndex = currentIndex + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOnJS(goToIndex)(newIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ translateX: translateX.value }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (!scrollOffset) return {};
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderDots = () => {
|
|
||||||
if (!hasItems || items.length <= 1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((_, index) => (
|
|
||||||
<DotIndicator
|
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
onPress={goToIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSkeletonLoader = () => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
|
||||||
position: "absolute",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dark Overlay Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Top Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Bottom Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Type and Genres Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: TEXT_SKELETON_HEIGHT,
|
|
||||||
width: TEXT_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: MAX_BUTTON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Played Status Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
height: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Dots Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[1, 2, 3].map((_, index) => (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor:
|
|
||||||
index === 0
|
|
||||||
? SKELETON_ACTIVE_DOT_COLOR
|
|
||||||
: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItem = (item: BaseItemDto, _index: number) => {
|
|
||||||
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={item.Id}
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Backdrop */}
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "absolute",
|
|
||||||
},
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
item={item}
|
|
||||||
variant='Backdrop'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Top */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Bottom */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Section */}
|
|
||||||
{itemLogoUrl && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => navigateToItem(item)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: itemLogoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Type and Genres Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
|
||||||
fontSize: GENRES_FONT_SIZE,
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
let typeLabel = "";
|
|
||||||
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
// For episodes, show season and episode number
|
|
||||||
const season = item.ParentIndexNumber;
|
|
||||||
const episode = item.IndexNumber;
|
|
||||||
if (season && episode) {
|
|
||||||
typeLabel = `S${season} • E${episode}`;
|
|
||||||
} else {
|
|
||||||
typeLabel = "Episode";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
typeLabel =
|
|
||||||
item.Type === "Series"
|
|
||||||
? "TV Show"
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? "Movie"
|
|
||||||
: item.Type || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const genres =
|
|
||||||
item.Genres && item.Genres.length > 0
|
|
||||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (typeLabel && genres) {
|
|
||||||
return `${typeLabel} • ${genres}`;
|
|
||||||
} else if (typeLabel) {
|
|
||||||
return typeLabel;
|
|
||||||
} else if (genres) {
|
|
||||||
return genres;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Section - for Episodes and Movies */}
|
|
||||||
{(item.Type === "Episode" || item.Type === "Movie") &&
|
|
||||||
item.Overview && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
numberOfLines={OVERVIEW_MAX_LINES}
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
|
||||||
fontSize: OVERVIEW_FONT_SIZE,
|
|
||||||
fontWeight: "400",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: OVERVIEW_MAX_WIDTH,
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button */}
|
|
||||||
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
|
||||||
{selectedOptions && (
|
|
||||||
<PlayButton
|
|
||||||
item={item}
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
colors={currentItemColors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Mark as Played */}
|
|
||||||
<MarkAsPlayedLargeButton
|
|
||||||
isPlayed={item.UserData?.Played ?? false}
|
|
||||||
onToggle={togglePlayedStatus}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderSkeletonLoader()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty items
|
|
||||||
if (!hasItems) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GestureDetector gesture={panGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
flexDirection: "row",
|
|
||||||
width: screenWidth * items.length,
|
|
||||||
},
|
|
||||||
containerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => renderItem(item, index))}
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
|
|
||||||
{/* Animated Dots Indicator */}
|
|
||||||
{renderDots()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Button, Host } from "@expo/ui/swift-ui";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { RoundButton } from "../RoundButton";
|
|
||||||
|
|
||||||
interface MarkAsPlayedLargeButtonProps {
|
|
||||||
isPlayed: boolean;
|
|
||||||
onToggle: (isPlayed: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarkAsPlayedLargeButton: React.FC<
|
|
||||||
MarkAsPlayedLargeButtonProps
|
|
||||||
> = ({ isPlayed, onToggle }) => {
|
|
||||||
if (Platform.OS === "ios")
|
|
||||||
return (
|
|
||||||
<Host
|
|
||||||
style={{
|
|
||||||
flex: 0,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
|
||||||
<View>
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Button>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={() => onToggle(isPlayed)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Casting Mini Player
|
|
||||||
* Works with all supported casting protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React from "react";
|
|
||||||
import { Pressable, View } from "react-native";
|
|
||||||
import Animated, { SlideInDown, SlideOutDown } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { useCasting } from "@/hooks/useCasting";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
formatTime,
|
|
||||||
getPosterUrl,
|
|
||||||
getProtocolIcon,
|
|
||||||
getProtocolName,
|
|
||||||
} from "@/utils/casting/helpers";
|
|
||||||
import { CASTING_CONSTANTS } from "@/utils/casting/types";
|
|
||||||
|
|
||||||
export const CastingMiniPlayer: React.FC = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const {
|
|
||||||
isConnected,
|
|
||||||
protocol,
|
|
||||||
currentItem,
|
|
||||||
currentDevice,
|
|
||||||
progress,
|
|
||||||
duration,
|
|
||||||
isPlaying,
|
|
||||||
togglePlayPause,
|
|
||||||
} = useCasting(null);
|
|
||||||
|
|
||||||
if (!isConnected || !currentItem || !protocol) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const posterUrl = getPosterUrl(
|
|
||||||
api?.basePath,
|
|
||||||
currentItem.Id,
|
|
||||||
currentItem.ImageTags?.Primary,
|
|
||||||
80,
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
|
|
||||||
const progressPercent = duration > 0 ? (progress / duration) * 100 : 0;
|
|
||||||
const protocolColor = "#a855f7"; // Streamyfin purple
|
|
||||||
const TAB_BAR_HEIGHT = 49; // Standard tab bar height
|
|
||||||
|
|
||||||
const handlePress = () => {
|
|
||||||
router.push("/casting-player");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
entering={SlideInDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
||||||
exiting={SlideOutDown.duration(CASTING_CONSTANTS.ANIMATION_DURATION)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: TAB_BAR_HEIGHT + insets.bottom,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: "#333",
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pressable onPress={handlePress}>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${progressPercent}%`,
|
|
||||||
backgroundColor: protocolColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 12,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Poster */}
|
|
||||||
{posterUrl && (
|
|
||||||
<Image
|
|
||||||
source={{ uri: posterUrl }}
|
|
||||||
style={{
|
|
||||||
width: 40,
|
|
||||||
height: 60,
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentItem.Name}
|
|
||||||
</Text>
|
|
||||||
{currentItem.SeriesName && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentItem.SeriesName}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={getProtocolIcon(protocol)}
|
|
||||||
size={12}
|
|
||||||
color={protocolColor}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: protocolColor,
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{currentDevice?.name || getProtocolName(protocol)}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#666",
|
|
||||||
fontSize: 11,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatTime(progress)} / {formatTime(duration)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Play/Pause button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isConnected && protocol) {
|
|
||||||
togglePlayPause();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name={isPlaying ? "pause" : "play"}
|
|
||||||
size={28}
|
|
||||||
color='white'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Device Info Sheet
|
|
||||||
* Shows device details, volume control, and disconnect option
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Modal, Pressable, View } from "react-native";
|
|
||||||
import { Slider } from "react-native-awesome-slider";
|
|
||||||
import type { Device } from "react-native-google-cast";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
|
|
||||||
interface ChromecastDeviceSheetProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
device: Device | null;
|
|
||||||
onDisconnect: () => Promise<void>;
|
|
||||||
volume?: number;
|
|
||||||
onVolumeChange?: (volume: number) => Promise<void>;
|
|
||||||
showTechnicalInfo?: boolean;
|
|
||||||
connectionQuality?: "excellent" | "good" | "fair" | "poor";
|
|
||||||
bitrate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastDeviceSheet: React.FC<ChromecastDeviceSheetProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
device,
|
|
||||||
onDisconnect,
|
|
||||||
volume = 0.5,
|
|
||||||
onVolumeChange,
|
|
||||||
showTechnicalInfo = false,
|
|
||||||
connectionQuality = "good",
|
|
||||||
bitrate,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
|
||||||
const volumeValue = useSharedValue(volume * 100);
|
|
||||||
|
|
||||||
// Sync volume slider with prop changes
|
|
||||||
useEffect(() => {
|
|
||||||
volumeValue.value = volume * 100;
|
|
||||||
}, [volume, volumeValue]);
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
setIsDisconnecting(true);
|
|
||||||
try {
|
|
||||||
await onDisconnect();
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect:", error);
|
|
||||||
} finally {
|
|
||||||
setIsDisconnecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeComplete = async (value: number) => {
|
|
||||||
if (onVolumeChange) {
|
|
||||||
await onVolumeChange(value / 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='tv' size={24} color='#a855f7' />
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
Chromecast
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Device info */}
|
|
||||||
<View style={{ padding: 16 }}>
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
|
||||||
Device Name
|
|
||||||
</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
|
||||||
{device?.friendlyName || device?.deviceId || "Unknown Device"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Connection Quality */}
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 8 }}>
|
|
||||||
Connection Quality
|
|
||||||
</Text>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundColor:
|
|
||||||
connectionQuality === "excellent"
|
|
||||||
? "#10b981"
|
|
||||||
: connectionQuality === "good"
|
|
||||||
? "#fbbf24"
|
|
||||||
: connectionQuality === "fair"
|
|
||||||
? "#f97316"
|
|
||||||
: "#ef4444",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
connectionQuality === "excellent"
|
|
||||||
? "#10b981"
|
|
||||||
: connectionQuality === "good"
|
|
||||||
? "#fbbf24"
|
|
||||||
: connectionQuality === "fair"
|
|
||||||
? "#f97316"
|
|
||||||
: "#ef4444",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{connectionQuality}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{bitrate && (
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginTop: 4 }}>
|
|
||||||
Bitrate: {(bitrate / 1000000).toFixed(1)} Mbps
|
|
||||||
{connectionQuality === "poor" &&
|
|
||||||
" (Low bitrate may cause buffering)"}
|
|
||||||
{connectionQuality === "fair" && " (Moderate quality)"}
|
|
||||||
{connectionQuality === "good" && " (Good quality)"}
|
|
||||||
{connectionQuality === "excellent" && " (Maximum quality)"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{device?.deviceId && showTechnicalInfo && (
|
|
||||||
<View style={{ marginBottom: 20 }}>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12, marginBottom: 4 }}>
|
|
||||||
Device ID
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 14 }}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{device.deviceId}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Volume control */}
|
|
||||||
<View style={{ marginBottom: 24 }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "#999", fontSize: 12 }}>Volume</Text>
|
|
||||||
<Text style={{ color: "white", fontSize: 14 }}>
|
|
||||||
{Math.round((volume || 0) * 100)}%
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='volume-low' size={20} color='#999' />
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Slider
|
|
||||||
style={{ width: "100%", height: 40 }}
|
|
||||||
progress={volumeValue}
|
|
||||||
minimumValue={useSharedValue(0)}
|
|
||||||
maximumValue={useSharedValue(100)}
|
|
||||||
theme={{
|
|
||||||
disableMinTrackTintColor: "#333",
|
|
||||||
maximumTrackTintColor: "#333",
|
|
||||||
minimumTrackTintColor: "#a855f7",
|
|
||||||
bubbleBackgroundColor: "#a855f7",
|
|
||||||
}}
|
|
||||||
onSlidingStart={() => {
|
|
||||||
console.log(
|
|
||||||
"[Volume] Sliding started",
|
|
||||||
volumeValue.value,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
volumeValue.value = value;
|
|
||||||
console.log("[Volume] Value changed", value);
|
|
||||||
}}
|
|
||||||
onSlidingComplete={handleVolumeComplete}
|
|
||||||
panHitSlop={{ top: 20, bottom: 20, left: 0, right: 0 }}
|
|
||||||
disable={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Ionicons name='volume-high' size={20} color='#999' />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Disconnect button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={handleDisconnect}
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#a855f7",
|
|
||||||
padding: 16,
|
|
||||||
borderRadius: 8,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 8,
|
|
||||||
opacity: isDisconnecting ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='power' size={20} color='white' />
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "600" }}>
|
|
||||||
{isDisconnecting ? "Disconnecting..." : "Stop Casting"}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
/**
|
|
||||||
* Episode List for Chromecast Player
|
|
||||||
* Displays list of episodes for TV shows with thumbnails
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { Api } from "@jellyfin/sdk";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import React, { useEffect, useRef } from "react";
|
|
||||||
import { FlatList, Modal, Pressable, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { truncateTitle } from "@/utils/casting/helpers";
|
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
||||||
|
|
||||||
interface ChromecastEpisodeListProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
currentItem: BaseItemDto | null;
|
|
||||||
episodes: BaseItemDto[];
|
|
||||||
onSelectEpisode: (episode: BaseItemDto) => void;
|
|
||||||
api: Api | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChromecastEpisodeList: React.FC<ChromecastEpisodeListProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
currentItem,
|
|
||||||
episodes,
|
|
||||||
onSelectEpisode,
|
|
||||||
api,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const flatListRef = useRef<FlatList>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible && currentItem && episodes.length > 0) {
|
|
||||||
const currentIndex = episodes.findIndex((ep) => ep.Id === currentItem.Id);
|
|
||||||
if (currentIndex !== -1 && flatListRef.current) {
|
|
||||||
// Delay to ensure FlatList is rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: currentIndex,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5, // Center the item
|
|
||||||
});
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [visible, currentItem, episodes]);
|
|
||||||
|
|
||||||
const renderEpisode = ({ item }: { item: BaseItemDto }) => {
|
|
||||||
const isCurrentEpisode = item.Id === currentItem?.Id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSelectEpisode(item);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: isCurrentEpisode ? "#a855f7" : "transparent",
|
|
||||||
borderRadius: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 120,
|
|
||||||
height: 68,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{api && item.Id && (
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: getPrimaryImageUrl({ api, item }) || undefined,
|
|
||||||
}}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
contentFit='cover'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(!api || !item.Id) && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='film-outline' size={32} color='#333' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode info */}
|
|
||||||
<View style={{ flex: 1, marginLeft: 12, justifyContent: "center" }}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "white",
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
|
||||||
{item.IndexNumber}. {truncateTitle(item.Name || "Unknown", 30)}
|
|
||||||
</Text>
|
|
||||||
{item.Overview && (
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: "#999",
|
|
||||||
fontSize: 12,
|
|
||||||
marginBottom: 4,
|
|
||||||
}}
|
|
||||||
numberOfLines={2}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
|
|
||||||
{item.ProductionYear && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.RunTimeTicks && (
|
|
||||||
<Text style={{ color: "#666", fontSize: 11 }}>
|
|
||||||
{Math.round(item.RunTimeTicks / 600000000)} min
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isCurrentEpisode && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
justifyContent: "center",
|
|
||||||
marginLeft: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name='play-circle' size={24} color='white' />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: insets.top,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
Episodes
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<FlatList
|
|
||||||
ref={flatListRef}
|
|
||||||
data={episodes}
|
|
||||||
renderItem={renderEpisode}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: insets.bottom + 16,
|
|
||||||
}}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
onScrollToIndexFailed={(info) => {
|
|
||||||
// Fallback if scroll fails
|
|
||||||
setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToIndex({
|
|
||||||
index: info.index,
|
|
||||||
animated: true,
|
|
||||||
viewPosition: 0.5,
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chromecast Settings Menu
|
|
||||||
* Allows users to configure audio, subtitles, quality, and playback speed
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Modal, Pressable, ScrollView, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import type {
|
|
||||||
AudioTrack,
|
|
||||||
MediaSource,
|
|
||||||
SubtitleTrack,
|
|
||||||
} from "@/utils/casting/types";
|
|
||||||
|
|
||||||
interface ChromecastSettingsMenuProps {
|
|
||||||
visible: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
item: BaseItemDto;
|
|
||||||
mediaSources: MediaSource[];
|
|
||||||
selectedMediaSource: MediaSource | null;
|
|
||||||
onMediaSourceChange: (source: MediaSource) => void;
|
|
||||||
audioTracks: AudioTrack[];
|
|
||||||
selectedAudioTrack: AudioTrack | null;
|
|
||||||
onAudioTrackChange: (track: AudioTrack) => void;
|
|
||||||
subtitleTracks: SubtitleTrack[];
|
|
||||||
selectedSubtitleTrack: SubtitleTrack | null;
|
|
||||||
onSubtitleTrackChange: (track: SubtitleTrack | null) => void;
|
|
||||||
playbackSpeed: number;
|
|
||||||
onPlaybackSpeedChange: (speed: number) => void;
|
|
||||||
showTechnicalInfo: boolean;
|
|
||||||
onToggleTechnicalInfo: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
|
||||||
|
|
||||||
export const ChromecastSettingsMenu: React.FC<ChromecastSettingsMenuProps> = ({
|
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
item: _item, // Reserved for future use (technical info display)
|
|
||||||
mediaSources,
|
|
||||||
selectedMediaSource,
|
|
||||||
onMediaSourceChange,
|
|
||||||
audioTracks,
|
|
||||||
selectedAudioTrack,
|
|
||||||
onAudioTrackChange,
|
|
||||||
subtitleTracks,
|
|
||||||
selectedSubtitleTrack,
|
|
||||||
onSubtitleTrackChange,
|
|
||||||
playbackSpeed,
|
|
||||||
onPlaybackSpeedChange,
|
|
||||||
showTechnicalInfo,
|
|
||||||
onToggleTechnicalInfo,
|
|
||||||
}) => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
|
||||||
setExpandedSection(expandedSection === section ? null : section);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSectionHeader = (
|
|
||||||
title: string,
|
|
||||||
icon: keyof typeof Ionicons.glyphMap,
|
|
||||||
sectionKey: string,
|
|
||||||
) => (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => toggleSection(sectionKey)}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
||||||
<Ionicons name={icon} size={20} color='white' />
|
|
||||||
<Text style={{ color: "white", fontSize: 16, fontWeight: "500" }}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Ionicons
|
|
||||||
name={expandedSection === sectionKey ? "chevron-up" : "chevron-down"}
|
|
||||||
size={20}
|
|
||||||
color='#999'
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent={true}
|
|
||||||
animationType='slide'
|
|
||||||
onRequestClose={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
}}
|
|
||||||
onPress={onClose}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#1a1a1a",
|
|
||||||
borderTopLeftRadius: 16,
|
|
||||||
borderTopRightRadius: 16,
|
|
||||||
maxHeight: "80%",
|
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
}}
|
|
||||||
onPress={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600" }}>
|
|
||||||
Playback Settings
|
|
||||||
</Text>
|
|
||||||
<Pressable onPress={onClose} style={{ padding: 8 }}>
|
|
||||||
<Ionicons name='close' size={24} color='white' />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView>
|
|
||||||
{/* Quality/Media Source */}
|
|
||||||
{renderSectionHeader("Quality", "film-outline", "quality")}
|
|
||||||
{expandedSection === "quality" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{mediaSources.map((source) => (
|
|
||||||
<Pressable
|
|
||||||
key={source.id}
|
|
||||||
onPress={() => {
|
|
||||||
onMediaSourceChange(source);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedMediaSource?.id === source.id
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{source.name}
|
|
||||||
</Text>
|
|
||||||
{source.bitrate && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{Math.round(source.bitrate / 1000000)} Mbps
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedMediaSource?.id === source.id && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Audio Tracks - only show if more than one track */}
|
|
||||||
{audioTracks.length > 1 &&
|
|
||||||
renderSectionHeader("Audio", "musical-notes", "audio")}
|
|
||||||
{audioTracks.length > 1 && expandedSection === "audio" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{audioTracks.map((track) => (
|
|
||||||
<Pressable
|
|
||||||
key={track.index}
|
|
||||||
onPress={() => {
|
|
||||||
onAudioTrackChange(track);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedAudioTrack?.index === track.index
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{track.displayTitle || track.language || "Unknown"}
|
|
||||||
</Text>
|
|
||||||
{track.codec && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{track.codec.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedAudioTrack?.index === track.index && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtitle Tracks - only show if subtitles available */}
|
|
||||||
{subtitleTracks.length > 0 &&
|
|
||||||
renderSectionHeader("Subtitles", "text", "subtitles")}
|
|
||||||
{subtitleTracks.length > 0 && expandedSection === "subtitles" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
onSubtitleTrackChange(null);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSubtitleTrack === null
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>None</Text>
|
|
||||||
{selectedSubtitleTrack === null && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
{subtitleTracks.map((track) => (
|
|
||||||
<Pressable
|
|
||||||
key={track.index}
|
|
||||||
onPress={() => {
|
|
||||||
onSubtitleTrackChange(track);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
selectedSubtitleTrack?.index === track.index
|
|
||||||
? "#2a2a2a"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{track.displayTitle || track.language || "Unknown"}
|
|
||||||
</Text>
|
|
||||||
{track.codec && (
|
|
||||||
<Text
|
|
||||||
style={{ color: "#999", fontSize: 13, marginTop: 2 }}
|
|
||||||
>
|
|
||||||
{track.codec.toUpperCase()}
|
|
||||||
{track.isForced && " • Forced"}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{selectedSubtitleTrack?.index === track.index && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Playback Speed */}
|
|
||||||
{renderSectionHeader("Playback Speed", "speedometer", "speed")}
|
|
||||||
{expandedSection === "speed" && (
|
|
||||||
<View style={{ paddingVertical: 8 }}>
|
|
||||||
{PLAYBACK_SPEEDS.map((speed) => (
|
|
||||||
<Pressable
|
|
||||||
key={speed}
|
|
||||||
onPress={() => {
|
|
||||||
onPlaybackSpeedChange(speed);
|
|
||||||
setExpandedSection(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor:
|
|
||||||
playbackSpeed === speed ? "#2a2a2a" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: "white", fontSize: 15 }}>
|
|
||||||
{speed === 1 ? "Normal" : `${speed}x`}
|
|
||||||
</Text>
|
|
||||||
{playbackSpeed === speed && (
|
|
||||||
<Ionicons name='checkmark' size={20} color='#a855f7' />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Technical Info Toggle */}
|
|
||||||
<Pressable
|
|
||||||
onPress={onToggleTechnicalInfo}
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: 16,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: "#333",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
|
|
||||||
>
|
|
||||||
<Ionicons name='information-circle' size={20} color='white' />
|
|
||||||
<Text
|
|
||||||
style={{ color: "white", fontSize: 16, fontWeight: "500" }}
|
|
||||||
>
|
|
||||||
Show Technical Info
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 50,
|
|
||||||
height: 30,
|
|
||||||
borderRadius: 15,
|
|
||||||
backgroundColor: showTechnicalInfo ? "#a855f7" : "#333",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: showTechnicalInfo ? "flex-end" : "flex-start",
|
|
||||||
padding: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 26,
|
|
||||||
height: 26,
|
|
||||||
borderRadius: 13,
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</ScrollView>
|
|
||||||
</Pressable>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hook for managing Chromecast segments (intro, credits, recap, commercial, preview)
|
|
||||||
* Integrates with autoskip API for segment detection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { isWithinSegment } from "@/utils/casting/helpers";
|
|
||||||
import type { ChromecastSegmentData } from "@/utils/chromecast/options";
|
|
||||||
import { useSegments } from "@/utils/segments";
|
|
||||||
|
|
||||||
export const useChromecastSegments = (
|
|
||||||
item: BaseItemDto | null,
|
|
||||||
currentProgressMs: number,
|
|
||||||
isOffline = false,
|
|
||||||
) => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const { settings } = useSettings();
|
|
||||||
|
|
||||||
// Fetch segments from autoskip API
|
|
||||||
const { data: segmentData } = useSegments(
|
|
||||||
item?.Id || "",
|
|
||||||
isOffline,
|
|
||||||
undefined, // downloadedFiles parameter
|
|
||||||
api,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse segments into usable format
|
|
||||||
const segments = useMemo<ChromecastSegmentData>(() => {
|
|
||||||
if (!segmentData) {
|
|
||||||
return {
|
|
||||||
intro: null,
|
|
||||||
credits: null,
|
|
||||||
recap: null,
|
|
||||||
commercial: [],
|
|
||||||
preview: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const intro =
|
|
||||||
segmentData.introSegments && segmentData.introSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.introSegments[0].startTime,
|
|
||||||
end: segmentData.introSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const credits =
|
|
||||||
segmentData.creditSegments && segmentData.creditSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.creditSegments[0].startTime,
|
|
||||||
end: segmentData.creditSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const recap =
|
|
||||||
segmentData.recapSegments && segmentData.recapSegments.length > 0
|
|
||||||
? {
|
|
||||||
start: segmentData.recapSegments[0].startTime,
|
|
||||||
end: segmentData.recapSegments[0].endTime,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const commercial = (segmentData.commercialSegments || []).map((seg) => ({
|
|
||||||
start: seg.startTime,
|
|
||||||
end: seg.endTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const preview = (segmentData.previewSegments || []).map((seg) => ({
|
|
||||||
start: seg.startTime,
|
|
||||||
end: seg.endTime,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { intro, credits, recap, commercial, preview };
|
|
||||||
}, [segmentData]);
|
|
||||||
|
|
||||||
// Check which segment we're currently in
|
|
||||||
const currentSegment = useMemo(() => {
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.intro)) {
|
|
||||||
return { type: "intro" as const, segment: segments.intro };
|
|
||||||
}
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.credits)) {
|
|
||||||
return { type: "credits" as const, segment: segments.credits };
|
|
||||||
}
|
|
||||||
if (isWithinSegment(currentProgressMs, segments.recap)) {
|
|
||||||
return { type: "recap" as const, segment: segments.recap };
|
|
||||||
}
|
|
||||||
for (const commercial of segments.commercial) {
|
|
||||||
if (isWithinSegment(currentProgressMs, commercial)) {
|
|
||||||
return { type: "commercial" as const, segment: commercial };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const preview of segments.preview) {
|
|
||||||
if (isWithinSegment(currentProgressMs, preview)) {
|
|
||||||
return { type: "preview" as const, segment: preview };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [currentProgressMs, segments]);
|
|
||||||
|
|
||||||
// Skip functions
|
|
||||||
const skipIntro = useCallback(
|
|
||||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
|
||||||
if (segments.intro) {
|
|
||||||
return seekFn(segments.intro.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.intro],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipCredits = useCallback(
|
|
||||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
|
||||||
if (segments.credits) {
|
|
||||||
return seekFn(segments.credits.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[segments.credits],
|
|
||||||
);
|
|
||||||
|
|
||||||
const skipSegment = useCallback(
|
|
||||||
(seekFn: (positionMs: number) => Promise<void>) => {
|
|
||||||
if (currentSegment?.segment) {
|
|
||||||
return seekFn(currentSegment.segment.end * 1000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentSegment],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-skip logic based on settings
|
|
||||||
const shouldAutoSkip = useMemo(() => {
|
|
||||||
if (!currentSegment) return false;
|
|
||||||
|
|
||||||
switch (currentSegment.type) {
|
|
||||||
case "intro":
|
|
||||||
return settings?.skipIntro === "auto";
|
|
||||||
case "credits":
|
|
||||||
return settings?.skipOutro === "auto";
|
|
||||||
case "recap":
|
|
||||||
return settings?.skipRecap === "auto";
|
|
||||||
case "commercial":
|
|
||||||
return settings?.skipCommercial === "auto";
|
|
||||||
case "preview":
|
|
||||||
return settings?.skipPreview === "auto";
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
currentSegment,
|
|
||||||
settings?.skipIntro,
|
|
||||||
settings?.skipOutro,
|
|
||||||
settings?.skipRecap,
|
|
||||||
settings?.skipCommercial,
|
|
||||||
settings?.skipPreview,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
segments,
|
|
||||||
currentSegment,
|
|
||||||
skipIntro,
|
|
||||||
skipCredits,
|
|
||||||
skipSegment,
|
|
||||||
shouldAutoSkip,
|
|
||||||
hasIntro: !!segments.intro,
|
|
||||||
hasCredits: !!segments.credits,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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),
|
||||||
<TextInput
|
useNativeDriver: true,
|
||||||
ref={inputRef}
|
}).start();
|
||||||
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"}
|
const handleFocus = () => {
|
||||||
text-white ${extraClassName}
|
setIsFocused(true);
|
||||||
`}
|
animateFocus(true);
|
||||||
allowFontScaling={false}
|
};
|
||||||
style={[
|
|
||||||
style,
|
const handleBlur = () => {
|
||||||
{
|
setIsFocused(false);
|
||||||
backgroundColor: isFocused ? "#ffffff88" : "#8f8d8d88",
|
animateFocus(false);
|
||||||
},
|
};
|
||||||
]}
|
|
||||||
placeholderTextColor={"#ffffffff"}
|
if (Platform.isTV) {
|
||||||
clearButtonMode='while-editing'
|
const containerStyle = {
|
||||||
onFocus={() => setIsFocused(true)}
|
height: 48,
|
||||||
onBlur={() => setIsFocused(false)}
|
borderRadius: 50,
|
||||||
{...otherProps}
|
borderWidth: isFocused ? 1.5 : 1,
|
||||||
/>
|
borderColor: isFocused
|
||||||
</TouchableOpacity>
|
? "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
|
||||||
|
ref={inputRef}
|
||||||
|
allowFontScaling={false}
|
||||||
|
placeholderTextColor='#666'
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "400",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|||||||
@@ -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"}`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
233
components/home/Favorites.tv.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
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 { useScaledTVTypography } 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 typography = useScaledTVTypography();
|
||||||
|
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: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("favorites.noDataTitle")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -595,11 +598,14 @@ export const Home = () => {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
// Render Streamystats sections after Recently Added sections
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
// For custom sections: place at the very end
|
||||||
? 0
|
const hasSuggestedMovies =
|
||||||
: 1;
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
const streamystatsIndex =
|
||||||
|
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
@@ -687,3 +693,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 />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -12,37 +12,38 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useSegments } from "expo-router";
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Animated,
|
||||||
TouchableOpacity,
|
Easing,
|
||||||
|
ScrollView,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import Animated, {
|
|
||||||
useAnimatedRef,
|
|
||||||
useScrollViewOffset,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||||
|
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
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 { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
// Generous gap between sections for Apple TV+ aesthetic
|
||||||
|
const SECTION_GAP = 24;
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
@@ -51,97 +52,137 @@ type InfiniteScrollingCollectionListSection = {
|
|||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
priority?: 1 | 2;
|
||||||
|
parentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaListSectionType = {
|
type Section = InfiniteScrollingCollectionListSection;
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export const HomeWithCarousel = () => {
|
export const Home = () => {
|
||||||
const router = useRouter();
|
const typography = useScaledTVTypography();
|
||||||
|
const _router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [_loading, setLoading] = useState(false);
|
const { settings } = useSettings();
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
|
||||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
serverConnected,
|
serverConnected,
|
||||||
loading: retryLoading,
|
loading: retryLoading,
|
||||||
retryCheck,
|
retryCheck,
|
||||||
} = useNetworkStatus();
|
} = useNetworkStatus();
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
// Dynamic backdrop state with debounce
|
||||||
if (isConnected && !prevIsConnected.current) {
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
invalidateCache();
|
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);
|
||||||
}
|
}
|
||||||
prevIsConnected.current = isConnected;
|
// Set new timer to update focused item after debounce delay
|
||||||
}, [isConnected, invalidateCache]);
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setFocusedItem(item);
|
||||||
const hasDownloads = useMemo(() => {
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
if (Platform.isTV) return false;
|
|
||||||
return downloadedItems.length > 0;
|
|
||||||
}, [downloadedItems]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name='download'
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, router, hasDownloads]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((_e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory"),
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const segments = useSegments();
|
// Cleanup debounce timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
return () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if (debounceTimerRef.current) {
|
||||||
animatedScrollRef.current?.scrollTo({
|
clearTimeout(debounceTimerRef.current);
|
||||||
y: Platform.isTV ? -152 : -100,
|
}
|
||||||
animated: true,
|
};
|
||||||
});
|
}, []);
|
||||||
|
|
||||||
|
// 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 () => {
|
return () => {
|
||||||
unsubscribe();
|
isCancelled = true;
|
||||||
};
|
};
|
||||||
}, [segments]);
|
}, [backdropUrl]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -162,6 +203,58 @@ export const HomeWithCarousel = () => {
|
|||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch hero items (Continue Watching + Next Up combined)
|
||||||
|
const { data: heroItems } = useQuery({
|
||||||
|
queryKey: ["home", "heroItems", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
|
getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
fields: ["Overview"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 10,
|
||||||
|
}),
|
||||||
|
getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user.Id,
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 10,
|
||||||
|
fields: ["Overview"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
|
// Combine, sort by recent activity, and dedupe
|
||||||
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
|
const sorted = combined.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 seen = new Set<string>();
|
||||||
|
const deduped: BaseItemDto[] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
if (!item.Id || seen.has(item.Id)) continue;
|
||||||
|
seen.add(item.Id);
|
||||||
|
deduped.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped.slice(0, 8);
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userViews = useMemo(
|
const userViews = useMemo(
|
||||||
@@ -178,44 +271,36 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
}, [userViews]);
|
}, [userViews]);
|
||||||
|
|
||||||
const _refetch = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
const createCollectionConfig = useCallback(
|
||||||
(
|
(
|
||||||
title: string,
|
title: string,
|
||||||
queryKey: string[],
|
queryKey: string[],
|
||||||
includeItemTypes: BaseItemKind[],
|
includeItemTypes: BaseItemKind[],
|
||||||
parentId: string | undefined,
|
parentId: string | undefined,
|
||||||
pageSize: number = 10,
|
pageSize = 10,
|
||||||
): InfiniteScrollingCollectionListSection => ({
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
title,
|
title,
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
if (!api) return [];
|
if (!api) return [];
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
limit: 100, // Fetch a larger set for pagination
|
limit: 10,
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
imageTypeLimit: 1,
|
imageTypeLimit: 1,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
parentId,
|
parentId,
|
||||||
})
|
})
|
||||||
).data || [];
|
).data || [];
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
pageSize,
|
pageSize,
|
||||||
|
parentId,
|
||||||
}),
|
}),
|
||||||
[api, user?.Id],
|
[api, user?.Id],
|
||||||
);
|
);
|
||||||
@@ -244,7 +329,6 @@ export const HomeWithCarousel = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to sort items by most recent activity
|
|
||||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||||
@@ -253,7 +337,6 @@ export const HomeWithCarousel = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to deduplicate items by ID
|
|
||||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
@@ -263,29 +346,25 @@ export const HomeWithCarousel = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the first sections based on merge setting
|
|
||||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: t("home.continue_and_next_up"),
|
title: t("home.continue_and_next_up"),
|
||||||
queryKey: ["home", "continueAndNextUp"],
|
queryKey: ["home", "continueAndNextUp"],
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
// Fetch both in parallel
|
|
||||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
getItemsApi(api).getResumeItems({
|
getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
getTvShowsApi(api).getNextUp({
|
getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: 0,
|
startIndex: 0,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -293,17 +372,16 @@ export const HomeWithCarousel = () => {
|
|||||||
const resumeItems = resumeResponse.data.Items || [];
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
const nextUpItems = nextUpResponse.data.Items || [];
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
// Combine, sort by recent activity, deduplicate
|
|
||||||
const combined = [...resumeItems, ...nextUpItems];
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
const sorted = sortByRecentActivity(combined);
|
const sorted = sortByRecentActivity(combined);
|
||||||
const deduplicated = deduplicateById(sorted);
|
const deduplicated = deduplicateById(sorted);
|
||||||
|
|
||||||
// Paginate client-side
|
|
||||||
return deduplicated.slice(pageParam, pageParam + 10);
|
return deduplicated.slice(pageParam, pageParam + 10);
|
||||||
},
|
},
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -314,9 +392,8 @@ export const HomeWithCarousel = () => {
|
|||||||
(
|
(
|
||||||
await getItemsApi(api).getResumeItems({
|
await getItemsApi(api).getResumeItems({
|
||||||
userId: user.Id,
|
userId: user.Id,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
@@ -324,6 +401,7 @@ export const HomeWithCarousel = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("home.next_up"),
|
title: t("home.next_up"),
|
||||||
@@ -332,23 +410,22 @@ export const HomeWithCarousel = () => {
|
|||||||
(
|
(
|
||||||
await getTvShowsApi(api).getNextUp({
|
await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: false,
|
enableResumable: false,
|
||||||
})
|
})
|
||||||
).data.Items || [],
|
).data.Items || [],
|
||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: "horizontal",
|
orientation: "horizontal",
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ss: Section[] = [
|
const ss: Section[] = [
|
||||||
...firstSections,
|
...firstSections,
|
||||||
...latestMediaViews,
|
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
...(!settings?.streamyStatsMovieRecommendations
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -367,6 +444,7 @@ export const HomeWithCarousel = () => {
|
|||||||
type: "InfiniteScrollingCollectionList" as const,
|
type: "InfiniteScrollingCollectionList" as const,
|
||||||
orientation: "vertical" as const,
|
orientation: "vertical" as const,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
|
priority: 2 as const,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -409,29 +487,26 @@ export const HomeWithCarousel = () => {
|
|||||||
if (section.nextUp) {
|
if (section.nextUp) {
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
limit: section.nextUp?.limit || pageSize,
|
limit: section.nextUp?.limit || pageSize,
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
});
|
});
|
||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
}
|
}
|
||||||
if (section.latest) {
|
if (section.latest) {
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
const allData =
|
||||||
(
|
(
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
limit: section.latest?.limit || 10,
|
||||||
isPlayed: section.latest?.isPlayed,
|
isPlayed: section.latest?.isPlayed,
|
||||||
groupItems: section.latest?.groupItems,
|
groupItems: section.latest?.groupItems,
|
||||||
})
|
})
|
||||||
).data || [];
|
).data || [];
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
}
|
}
|
||||||
if (section.custom) {
|
if (section.custom) {
|
||||||
@@ -454,6 +529,7 @@ export const HomeWithCarousel = () => {
|
|||||||
type: "InfiniteScrollingCollectionList",
|
type: "InfiniteScrollingCollectionList",
|
||||||
orientation: section?.orientation || "vertical",
|
orientation: section?.orientation || "vertical",
|
||||||
pageSize,
|
pageSize,
|
||||||
|
priority: index < 2 ? 1 : 2,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return ss;
|
return ss;
|
||||||
@@ -461,6 +537,24 @@ export const HomeWithCarousel = () => {
|
|||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
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) {
|
if (!isConnected || serverConnected !== true) {
|
||||||
let title = "";
|
let title = "";
|
||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
@@ -476,32 +570,44 @@ export const HomeWithCarousel = () => {
|
|||||||
subtitle = t("home.server_unreachable_message");
|
subtitle = t("home.server_unreachable_message");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
<View
|
||||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
style={{
|
||||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
<View className='mt-4'>
|
justifyContent: "center",
|
||||||
{!Platform.isTV && (
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
<Button
|
}}
|
||||||
color='purple'
|
>
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
<Text
|
||||||
justify='center'
|
style={{
|
||||||
iconRight={
|
fontSize: typography.heading,
|
||||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
fontWeight: "bold",
|
||||||
}
|
marginBottom: 8,
|
||||||
>
|
color: "#FFFFFF",
|
||||||
{t("home.go_to_downloads")}
|
}}
|
||||||
</Button>
|
>
|
||||||
)}
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
<Button
|
<Button
|
||||||
color='black'
|
color='black'
|
||||||
onPress={retryCheck}
|
onPress={retryCheck}
|
||||||
justify='center'
|
justify='center'
|
||||||
className='mt-2'
|
className='px-4'
|
||||||
iconRight={
|
iconRight={
|
||||||
retryLoading ? null : (
|
retryLoading ? null : (
|
||||||
<Ionicons name='refresh' size={20} color='white' />
|
<Ionicons name='refresh' size={24} color='white' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -518,9 +624,31 @@ export const HomeWithCarousel = () => {
|
|||||||
|
|
||||||
if (e1)
|
if (e1)
|
||||||
return (
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
<View
|
||||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
style={{
|
||||||
<Text className='text-center opacity-70'>
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.oops")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("home.error_message")}
|
{t("home.error_message")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -528,55 +656,126 @@ export const HomeWithCarousel = () => {
|
|||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className='justify-center items-center h-full'>
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine if hero should be shown (separate setting from backdrop)
|
||||||
|
const showHero =
|
||||||
|
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.ScrollView
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
scrollToOverflowEnabled={true}
|
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
|
||||||
ref={animatedScrollRef}
|
{!showHero && settings.showHomeBackdrop && (
|
||||||
nestedScrollEnabled
|
<View
|
||||||
contentInsetAdjustmentBehavior='never'
|
style={{
|
||||||
scrollEventThrottle={16}
|
position: "absolute",
|
||||||
bounces={false}
|
top: 0,
|
||||||
overScrollMode='never'
|
left: 0,
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
right: 0,
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
bottom: 0,
|
||||||
onScroll={(event) => {
|
}}
|
||||||
setScrollY(event.nativeEvent.contentOffset.y);
|
>
|
||||||
}}
|
{/* Layer 0 */}
|
||||||
>
|
<Animated.View
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
style={{
|
||||||
<View
|
position: "absolute",
|
||||||
style={{
|
width: "100%",
|
||||||
paddingLeft: insets.left,
|
height: "100%",
|
||||||
paddingRight: insets.right,
|
opacity: layer0Opacity,
|
||||||
paddingBottom: 16,
|
}}
|
||||||
paddingTop: 0,
|
>
|
||||||
|
{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: showHero ? 0 : insets.top + TOP_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className='flex flex-col space-y-4'>
|
{/* Hero Carousel - Apple TV+ style featured content */}
|
||||||
{sections.map((section, index) => {
|
{showHero && (
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
)}
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
|
||||||
? 0
|
<View
|
||||||
: 1;
|
style={{
|
||||||
|
gap: SECTION_GAP,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingTop: showHero ? SECTION_GAP : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
|
||||||
|
{sections.slice(showHero ? 1 : 0).map((section, index) => {
|
||||||
|
// Render Streamystats sections after Recently Added sections
|
||||||
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
|
// For custom sections: place at the very end
|
||||||
|
const hasSuggestedMovies =
|
||||||
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
// Adjust index calculation to account for sliced array when hero is shown
|
||||||
|
const displayedSectionsLength =
|
||||||
|
sections.length - (showHero ? 1 : 0);
|
||||||
|
const streamystatsIndex =
|
||||||
|
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
settings.streamyStatsPromotedWatchlists;
|
settings.streamyStatsPromotedWatchlists;
|
||||||
const streamystatsSections =
|
const streamystatsSections =
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||||
<>
|
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
{settings.streamyStatsMovieRecommendations && (
|
||||||
<StreamystatsRecommendations
|
<StreamystatsRecommendations
|
||||||
title={t(
|
title={t(
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
)}
|
)}
|
||||||
type='Movie'
|
type='Movie'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
{settings.streamyStatsSeriesRecommendations && (
|
||||||
@@ -585,17 +784,25 @@ export const HomeWithCarousel = () => {
|
|||||||
"home.settings.plugins.streamystats.recommended_series",
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
)}
|
)}
|
||||||
type='Series'
|
type='Series'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
{settings.streamyStatsPromotedWatchlists && (
|
||||||
<StreamystatsPromotedWatchlists />
|
<StreamystatsPromotedWatchlists
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
|
const isHighPriority = section.priority === 1;
|
||||||
|
// First section only gets preferred focus if hero is not shown
|
||||||
|
const isFirstSection = index === 0 && !showHero;
|
||||||
return (
|
return (
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
title={section.title}
|
title={section.title}
|
||||||
queryKey={section.queryKey}
|
queryKey={section.queryKey}
|
||||||
@@ -603,19 +810,15 @@ export const HomeWithCarousel = () => {
|
|||||||
orientation={section.orientation}
|
orientation={section.orientation}
|
||||||
hideIfEmpty
|
hideIfEmpty
|
||||||
pageSize={section.pageSize}
|
pageSize={section.pageSize}
|
||||||
/>
|
enabled={isHighPriority || allHighPriorityLoaded}
|
||||||
{streamystatsSections}
|
onLoaded={
|
||||||
</View>
|
isHighPriority
|
||||||
);
|
? () => markSectionLoaded(section.queryKey)
|
||||||
}
|
: undefined
|
||||||
if (section.type === "MediaListSection") {
|
}
|
||||||
return (
|
isFirstSection={isFirstSection}
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
onItemFocus={handleItemFocus}
|
||||||
<MediaListSection
|
parentId={section.parentId}
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
scrollY={scrollY}
|
|
||||||
enableLazyLoading={true}
|
|
||||||
/>
|
/>
|
||||||
{streamystatsSections}
|
{streamystatsSections}
|
||||||
</View>
|
</View>
|
||||||
@@ -624,8 +827,7 @@ export const HomeWithCarousel = () => {
|
|||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
<View className='h-24' />
|
</View>
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
498
components/home/InfiniteScrollingCollectionList.tv.tsx
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
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 { useScaledTVTypography } 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 = 24;
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
// TV-specific ItemCardText with larger fonts
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.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;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
|
||||||
|
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: typography.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 typography = useScaledTVTypography();
|
||||||
|
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,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
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} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
orientation,
|
||||||
|
isFirstSection,
|
||||||
|
itemWidth,
|
||||||
|
handleItemPress,
|
||||||
|
handleItemFocus,
|
||||||
|
handleItemBlur,
|
||||||
|
typography,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
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: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading === false && allItems.length === 0 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: typography.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: typography.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}
|
||||||
|
typography={typography}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled: enableLazyLoading ? isInView : true,
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
349
components/home/StreamystatsPromotedWatchlists.tv.tsx
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
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 { useScaledTVTypography } 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;
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.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 typography = useScaledTVTypography();
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
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} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleItemPress, onItemFocus, typography],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
enabled:
|
enabled:
|
||||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
282
components/home/StreamystatsRecommendations.tv.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
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 { useScaledTVTypography } 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;
|
||||||
|
|
||||||
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
type: "Movie" | "Series";
|
||||||
|
limit?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
limit = 20,
|
||||||
|
enabled = true,
|
||||||
|
onItemFocus,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
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: 60 * 1000,
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
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} typography={typography} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleItemPress, onItemFocus, typography],
|
||||||
|
);
|
||||||
|
|
||||||
|
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: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
624
components/home/TVHeroCarousel.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
|
||||||
|
const CARD_WIDTH = 280;
|
||||||
|
const CARD_GAP = 24;
|
||||||
|
const CARD_PADDING = 60;
|
||||||
|
|
||||||
|
interface TVHeroCarouselProps {
|
||||||
|
items: BaseItemDto[];
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCardProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
isFirst: boolean;
|
||||||
|
onFocus: (item: BaseItemDto) => void;
|
||||||
|
onPress: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||||
|
({ item, isFirst, onFocus, onPress }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// Check if glass effect is available (tvOS 26+)
|
||||||
|
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||||
|
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
// For episodes, always use series thumb
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
if (item.SeriesId) {
|
||||||
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-episodes, use item's own thumb/primary
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
|
||||||
|
}
|
||||||
|
if (item.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(value: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(),
|
||||||
|
[scale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.1);
|
||||||
|
onFocus(item);
|
||||||
|
}, [animateTo, onFocus, item]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}, [animateTo]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onPress(item);
|
||||||
|
}, [onPress, item]);
|
||||||
|
|
||||||
|
// Use glass poster for tvOS 26+
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: CARD_GAP }}
|
||||||
|
>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={posterUrl}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={16}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={false}
|
||||||
|
isFocused={focused}
|
||||||
|
width={CARD_WIDTH}
|
||||||
|
style={{ width: CARD_WIDTH }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-tvOS or older tvOS
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: CARD_GAP }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='film-outline'
|
||||||
|
size={48}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ProgressBar item={item} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce delay to prevent rapid backdrop changes when navigating fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||||
|
items,
|
||||||
|
onItemFocus,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Active item for featured display (debounced)
|
||||||
|
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
|
||||||
|
items[0] || null,
|
||||||
|
);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Crossfade animation state
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Get backdrop URL for active item
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: activeItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Get logo URL for active item
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getLogoImageUrlById({ api, item: activeItem });
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Crossfade effect for backdrop
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Handle card focus with debounce
|
||||||
|
const handleCardFocus = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
// Clear any pending debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
// Set new timer to update active item after debounce delay
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setActiveItem(item);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle card press - navigate to item
|
||||||
|
const handleCardPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get metadata for active item
|
||||||
|
const year = activeItem?.ProductionYear;
|
||||||
|
const duration = activeItem?.RunTimeTicks
|
||||||
|
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
|
||||||
|
: null;
|
||||||
|
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
|
||||||
|
|
||||||
|
// Get display title
|
||||||
|
const displayTitle = useMemo(() => {
|
||||||
|
if (!activeItem) return "";
|
||||||
|
if (activeItem.Type === "Episode") {
|
||||||
|
return activeItem.SeriesName || activeItem.Name || "";
|
||||||
|
}
|
||||||
|
return activeItem.Name || "";
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Get subtitle for episodes
|
||||||
|
const episodeSubtitle = useMemo(() => {
|
||||||
|
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||||
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Memoize hero items to prevent re-renders
|
||||||
|
const heroItems = useMemo(() => items.slice(0, 8), [items]);
|
||||||
|
|
||||||
|
// Memoize renderItem for FlatList
|
||||||
|
const renderHeroCard = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<HeroCard
|
||||||
|
item={item}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onFocus={handleCardFocus}
|
||||||
|
onPress={handleCardPress}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleCardFocus, handleCardPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize keyExtractor
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ height: HERO_HEIGHT, width: "100%" }}>
|
||||||
|
{/* Backdrop layers 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 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.4)", "transparent"]}
|
||||||
|
locations={[0, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "40%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Horizontal gradient for left side text contrast */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
|
||||||
|
locations={[0, 0.5, 0.85]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content overlay */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: insets.left + CARD_PADDING,
|
||||||
|
right: insets.right + CARD_PADDING,
|
||||||
|
bottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: SCREEN_WIDTH * 0.35,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode subtitle */}
|
||||||
|
{episodeSubtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{episodeSubtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{activeItem?.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
marginBottom: 16,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.5,
|
||||||
|
lineHeight: typography.body * 1.4,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{activeItem.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{activeItem?.OfficialRating && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255,255,255,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.OfficialRating}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasProgress && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.3)",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${playedPercent}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(playedPercent)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Thumbnail carousel */}
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={heroItems}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ paddingVertical: 12 }}
|
||||||
|
renderItem={renderHeroCard}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
141
components/inputs/TVPinInput.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -47,7 +47,7 @@ export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MoreMoviesWithActor
|
<MoreMoviesWithActor
|
||||||
key={`${person.Id}-${idx}`}
|
key={person.Id}
|
||||||
currentItem={item}
|
currentItem={item}
|
||||||
actorId={person.Id}
|
actorId={person.Id}
|
||||||
actorName={person.Name}
|
actorName={person.Name}
|
||||||
|
|||||||
47
components/jellyseerr/discover/TVDiscover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
270
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
type DiscoverEndpoint,
|
||||||
|
Endpoints,
|
||||||
|
useJellyseerr,
|
||||||
|
} from "@/hooks/useJellyseerr";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface TVDiscoverPosterProps {
|
||||||
|
item: MovieResult | TvResult;
|
||||||
|
isFirstItem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||||
|
item,
|
||||||
|
isFirstItem = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const router = useRouter();
|
||||||
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
const posterUrl = item.posterPath
|
||||||
|
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const title = getTitle(item);
|
||||||
|
const year = getYear(item);
|
||||||
|
|
||||||
|
const isInLibrary =
|
||||||
|
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||||
|
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||||
|
params: {
|
||||||
|
id: String(item.id),
|
||||||
|
mediaType: item.mediaType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirstItem}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 210,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 210,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='image-outline'
|
||||||
|
size={40}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isInLibrary && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVDiscoverSlideProps {
|
||||||
|
slide: DiscoverSlider;
|
||||||
|
isFirstSlide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||||
|
slide,
|
||||||
|
isFirstSlide = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "discover", "tv", slide.id],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
let endpoint: DiscoverEndpoint | undefined;
|
||||||
|
let params: Record<string, unknown> = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (slide.type) {
|
||||||
|
case DiscoverSliderType.TRENDING:
|
||||||
|
endpoint = Endpoints.DISCOVER_TRENDING;
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_MOVIES:
|
||||||
|
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||||
|
endpoint = Endpoints.DISCOVER_MOVIES;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case DiscoverSliderType.POPULAR_TV:
|
||||||
|
case DiscoverSliderType.UPCOMING_TV:
|
||||||
|
endpoint = Endpoints.DISCOVER_TV;
|
||||||
|
if (slide.type === DiscoverSliderType.UPCOMING_TV)
|
||||||
|
params = {
|
||||||
|
...params,
|
||||||
|
firstAirDateGte: new Date().toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
enabled: !!jellyseerrApi,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(
|
||||||
|
data?.pages
|
||||||
|
?.filter((p) => p?.results.length)
|
||||||
|
.flatMap((p) =>
|
||||||
|
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
|
||||||
|
),
|
||||||
|
"id",
|
||||||
|
) as (MovieResult | TvResult)[],
|
||||||
|
[data, isJellyseerrMovieOrTvResult],
|
||||||
|
);
|
||||||
|
|
||||||
|
const slideTitle = t(
|
||||||
|
`search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flatData || flatData.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{slideTitle}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={flatData}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) fetchNextPage();
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<TVDiscoverPoster
|
||||||
|
item={item}
|
||||||
|
isFirstItem={isFirstSlide && index === 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
877
components/jellyseerr/tv/TVJellyseerrPage.tsx
Normal file
@@ -0,0 +1,877 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import { TVButton } from "@/components/tv";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
|
import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||||
|
import type Season from "@/utils/jellyseerr/server/entity/Season";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
Permission,
|
||||||
|
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
// Cast card component
|
||||||
|
interface TVCastCardProps {
|
||||||
|
person: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
character?: string;
|
||||||
|
profilePath?: string;
|
||||||
|
};
|
||||||
|
imageProxy: (path: string, size?: string) => string;
|
||||||
|
onPress: () => void;
|
||||||
|
refSetter?: (ref: View | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||||
|
person,
|
||||||
|
imageProxy,
|
||||||
|
onPress,
|
||||||
|
refSetter,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
const profileUrl = person.profilePath
|
||||||
|
? imageProxy(person.profilePath, "w185")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={refSetter}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 140,
|
||||||
|
alignItems: "center",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: focused ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profileUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: profileUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person' size={48} color='rgba(255,255,255,0.4)' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</Text>
|
||||||
|
{person.character && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: focused
|
||||||
|
? "rgba(255,255,255,0.7)"
|
||||||
|
: "rgba(255,255,255,0.5)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{person.character}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVJellyseerrPage: React.FC = () => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||||
|
params as unknown as {
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: string;
|
||||||
|
posterSrc: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
const { showRequestModal } = useTVRequestModal();
|
||||||
|
const { showSeasonSelectModal } = useTVSeasonSelectModal();
|
||||||
|
|
||||||
|
// Refs for TVFocusGuideView destinations (useState triggers re-render when set)
|
||||||
|
const [playButtonRef, setPlayButtonRef] = useState<View | null>(null);
|
||||||
|
const [firstCastCardRef, setFirstCastCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: details,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
|
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
queryFn: async () => {
|
||||||
|
return mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieDetails(result.id!)
|
||||||
|
: jellyseerrApi?.tvDetails(result.id!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const canManageRequests = useMemo(() => {
|
||||||
|
if (!jellyseerrUser) return false;
|
||||||
|
return hasPermission(
|
||||||
|
Permission.MANAGE_REQUESTS,
|
||||||
|
jellyseerrUser.permissions,
|
||||||
|
);
|
||||||
|
}, [jellyseerrUser]);
|
||||||
|
|
||||||
|
const pendingRequest = useMemo(() => {
|
||||||
|
return details?.mediaInfo?.requests?.find(
|
||||||
|
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||||
|
);
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
// Get seasons with status for TV shows
|
||||||
|
const seasons = useMemo(() => {
|
||||||
|
if (!details || mediaType !== MediaType.TV) return [];
|
||||||
|
const tvDetails = details as TvDetails;
|
||||||
|
const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter(
|
||||||
|
(s: Season) => s.seasonNumber !== 0,
|
||||||
|
);
|
||||||
|
const requestedSeasons =
|
||||||
|
tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
|
||||||
|
[];
|
||||||
|
return (
|
||||||
|
tvDetails.seasons?.map((season) => ({
|
||||||
|
...season,
|
||||||
|
status:
|
||||||
|
mediaInfoSeasons?.find(
|
||||||
|
(mediaSeason: Season) =>
|
||||||
|
mediaSeason.seasonNumber === season.seasonNumber,
|
||||||
|
)?.status ??
|
||||||
|
requestedSeasons?.find(
|
||||||
|
(s: Season) => s.seasonNumber === season.seasonNumber,
|
||||||
|
)?.status ??
|
||||||
|
MediaStatus.UNKNOWN,
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}, [details, mediaType]);
|
||||||
|
|
||||||
|
const _allSeasonsAvailable = useMemo(
|
||||||
|
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
|
||||||
|
[seasons],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if there are any requestable seasons (status === UNKNOWN)
|
||||||
|
const hasRequestableSeasons = useMemo(
|
||||||
|
() =>
|
||||||
|
seasons.some(
|
||||||
|
(season) =>
|
||||||
|
season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN,
|
||||||
|
),
|
||||||
|
[seasons],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get cast
|
||||||
|
const cast = useMemo(() => {
|
||||||
|
return details?.credits?.cast?.slice(0, 10) ?? [];
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
// Backdrop URL
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
const path = details?.backdropPath || result.backdropPath;
|
||||||
|
return path
|
||||||
|
? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces")
|
||||||
|
: null;
|
||||||
|
}, [details, result.backdropPath, jellyseerrApi]);
|
||||||
|
|
||||||
|
// Poster URL
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (posterSrc) return posterSrc;
|
||||||
|
const path = details?.posterPath;
|
||||||
|
return path ? jellyseerrApi?.imageProxy(path, "w342") : null;
|
||||||
|
}, [posterSrc, details, jellyseerrApi]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleApproveRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||||
|
refetch();
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleDeclineRequest = useCallback(async () => {
|
||||||
|
if (!pendingRequest?.id) return;
|
||||||
|
try {
|
||||||
|
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||||
|
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||||
|
refetch();
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||||
|
|
||||||
|
const handleRequest = useCallback(async () => {
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
mediaType: mediaType!,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
...(mediaType === MediaType.TV && {
|
||||||
|
seasons: (details as TvDetails)?.seasons
|
||||||
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasAdvancedRequestPermission) {
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: mediaTitle,
|
||||||
|
id: result.id!,
|
||||||
|
mediaType: mediaType!,
|
||||||
|
onRequested: refetch,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMedia(mediaTitle, body, refetch);
|
||||||
|
}, [
|
||||||
|
details,
|
||||||
|
result,
|
||||||
|
requestMedia,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
mediaTitle,
|
||||||
|
refetch,
|
||||||
|
mediaType,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleRequestAll = useCallback(() => {
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
seasons: seasons
|
||||||
|
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
|
||||||
|
.map((s) => s.seasonNumber),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasAdvancedRequestPermission) {
|
||||||
|
showRequestModal({
|
||||||
|
requestBody: body,
|
||||||
|
title: mediaTitle,
|
||||||
|
id: result.id!,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
onRequested: refetch,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch);
|
||||||
|
}, [
|
||||||
|
details,
|
||||||
|
result,
|
||||||
|
seasons,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
requestMedia,
|
||||||
|
mediaTitle,
|
||||||
|
refetch,
|
||||||
|
t,
|
||||||
|
showRequestModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleOpenSeasonSelectModal = useCallback(() => {
|
||||||
|
showSeasonSelectModal({
|
||||||
|
seasons: seasons.filter((s) => s.seasonNumber !== 0),
|
||||||
|
title: mediaTitle,
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
onRequested: refetch,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
seasons,
|
||||||
|
mediaTitle,
|
||||||
|
result,
|
||||||
|
details,
|
||||||
|
hasAdvancedRequestPermission,
|
||||||
|
refetch,
|
||||||
|
showSeasonSelectModal,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
|
||||||
|
if (!jellyfinMediaId) return;
|
||||||
|
router.push({
|
||||||
|
pathname:
|
||||||
|
mediaType === MediaType.MOVIE
|
||||||
|
? "/(auth)/(tabs)/(search)/items/page"
|
||||||
|
: "/(auth)/(tabs)/(search)/series/[id]",
|
||||||
|
params: { id: jellyfinMediaId },
|
||||||
|
});
|
||||||
|
}, [details, mediaType, router]);
|
||||||
|
|
||||||
|
const handleCastPress = useCallback(
|
||||||
|
(personId: number) => {
|
||||||
|
router.push(`/(auth)/jellyseerr/person/${personId}` as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId;
|
||||||
|
const requestedByName =
|
||||||
|
pendingRequest?.requestedBy?.displayName ||
|
||||||
|
pendingRequest?.requestedBy?.username ||
|
||||||
|
pendingRequest?.requestedBy?.jellyfinUsername ||
|
||||||
|
t("jellyseerr.unknown_user");
|
||||||
|
|
||||||
|
if (isLoading || isFetching) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000" }}>
|
||||||
|
{/* Full-screen backdrop */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backdropUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: backdropUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
|
)}
|
||||||
|
{/* Bottom gradient */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Left gradient */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0.6, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "60%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 140,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top section - Poster + Content */}
|
||||||
|
<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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='image-outline'
|
||||||
|
size={48}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Ratings */}
|
||||||
|
{details && (
|
||||||
|
<JellyserrRatings
|
||||||
|
result={
|
||||||
|
details as MovieDetails | TvDetails | MovieResult | TvResult
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{mediaTitle}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Year */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{releaseYear}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{details?.genres && details.genres.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<GenreTags genres={details.genres.map((g) => g.name)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{(details?.overview || result.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: typography.body,
|
||||||
|
color: "#E5E7EB",
|
||||||
|
lineHeight: 32,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{details?.overview || result.overview}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasJellyfinMedia && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handlePlay}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
variant='primary'
|
||||||
|
refSetter={setPlayButtonRef}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='play'
|
||||||
|
size={28}
|
||||||
|
color='#000000'
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.play")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request button - only show for movies, TV series use Request All + season cards */}
|
||||||
|
{canRequest && mediaType === MediaType.MOVIE && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||||
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||||
|
scaleAmount={1.01}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={24}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request All button for TV series */}
|
||||||
|
{mediaType === MediaType.TV &&
|
||||||
|
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
||||||
|
hasRequestableSeasons && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequestAll}
|
||||||
|
variant='secondary'
|
||||||
|
hasTVPreferredFocus={!hasJellyfinMedia}
|
||||||
|
refSetter={!hasJellyfinMedia ? setPlayButtonRef : undefined}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='bag-add'
|
||||||
|
size={20}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_all")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request Seasons button for TV series */}
|
||||||
|
{mediaType === MediaType.TV &&
|
||||||
|
seasons.filter((s) => s.seasonNumber !== 0).length > 0 &&
|
||||||
|
hasRequestableSeasons && (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleOpenSeasonSelectModal}
|
||||||
|
variant='secondary'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='list'
|
||||||
|
size={20}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_seasons")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TVButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Approve/Decline for managers */}
|
||||||
|
{canManageRequests && pendingRequest && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.requested_by", { user: requestedByName })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: "row", gap: 16 }}>
|
||||||
|
<TVButton onPress={handleApproveRequest} variant='secondary'>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.approve")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
|
||||||
|
<TVButton onPress={handleDeclineRequest} variant='secondary'>
|
||||||
|
<Ionicons
|
||||||
|
name='close'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.decline")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Cast section */}
|
||||||
|
{cast.length > 0 && jellyseerrApi && (
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.cast")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Focus guides for bidirectional navigation - stacked together */}
|
||||||
|
{/* Downward: action buttons → first cast card */}
|
||||||
|
{firstCastCardRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstCastCardRef]}
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
marginLeft: -(insets.left + 80),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Upward: cast → action buttons */}
|
||||||
|
{playButtonRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[playButtonRef]}
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
width: SCREEN_WIDTH,
|
||||||
|
marginLeft: -(insets.left + 80),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: 16,
|
||||||
|
gap: 28,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cast.map((person, index) => (
|
||||||
|
<TVCastCard
|
||||||
|
key={person.id}
|
||||||
|
person={person}
|
||||||
|
imageProxy={(path, size) =>
|
||||||
|
jellyseerrApi.imageProxy(path, size || "w185")
|
||||||
|
}
|
||||||
|
onPress={() => handleCastPress(person.id)}
|
||||||
|
refSetter={index === 0 ? setFirstCastCardRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
519
components/jellyseerr/tv/TVRequestModal.tsx
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
BackHandler,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import type {
|
||||||
|
QualityProfile,
|
||||||
|
RootFolder,
|
||||||
|
Tag,
|
||||||
|
} from "@/utils/jellyseerr/server/api/servarr/base";
|
||||||
|
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import { TVRequestOptionRow } from "./TVRequestOptionRow";
|
||||||
|
import { TVToggleOptionRow } from "./TVToggleOptionRow";
|
||||||
|
|
||||||
|
interface TVRequestModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
requestBody?: MediaRequestBody;
|
||||||
|
title: string;
|
||||||
|
id: number;
|
||||||
|
mediaType: MediaType;
|
||||||
|
onClose: () => void;
|
||||||
|
onRequested: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
||||||
|
visible,
|
||||||
|
requestBody,
|
||||||
|
title,
|
||||||
|
id,
|
||||||
|
mediaType,
|
||||||
|
onClose,
|
||||||
|
onRequested,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||||
|
mediaId: Number(id),
|
||||||
|
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
// Handle back button to close modal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const handleBackPress = () => {
|
||||||
|
// If a sub-selector is open, close it first
|
||||||
|
if (activeSelector) {
|
||||||
|
setActiveSelector(null);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
return true; // Prevent default back behavior
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = BackHandler.addEventListener(
|
||||||
|
"hardwareBackPress",
|
||||||
|
handleBackPress,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [visible, activeSelector, onClose]);
|
||||||
|
|
||||||
|
const { data: serviceSettings } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "request", mediaType, "service"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "users"],
|
||||||
|
queryFn: async () =>
|
||||||
|
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultService = useMemo(
|
||||||
|
() => serviceSettings?.find?.((v) => v.isDefault),
|
||||||
|
[serviceSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: defaultServiceDetails } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"jellyseerr",
|
||||||
|
"request",
|
||||||
|
mediaType,
|
||||||
|
"service",
|
||||||
|
"details",
|
||||||
|
defaultService?.id,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
setRequestOverrides((prev) => ({
|
||||||
|
...prev,
|
||||||
|
serverId: defaultService?.id,
|
||||||
|
}));
|
||||||
|
return jellyseerrApi?.serviceDetails(
|
||||||
|
mediaType === "movie" ? "radarr" : "sonarr",
|
||||||
|
defaultService!.id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const body = {
|
||||||
|
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||||
|
profileId: defaultProfile?.id,
|
||||||
|
rootFolder: defaultFolder?.path,
|
||||||
|
tags: defaultTags.map((t) => t.id),
|
||||||
|
...requestBody,
|
||||||
|
...requestOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
const seasonTitle =
|
||||||
|
requestBody?.seasons?.length === 1
|
||||||
|
? t("jellyseerr.season_number", {
|
||||||
|
season_number: requestBody.seasons[0],
|
||||||
|
})
|
||||||
|
: requestBody?.seasons && requestBody.seasons.length > 1
|
||||||
|
? t("jellyseerr.season_all")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
requestMedia(
|
||||||
|
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||||
|
body,
|
||||||
|
onRequested,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
requestBody,
|
||||||
|
requestOverrides,
|
||||||
|
defaultProfile,
|
||||||
|
defaultFolder,
|
||||||
|
defaultTags,
|
||||||
|
defaultService,
|
||||||
|
defaultServiceDetails,
|
||||||
|
title,
|
||||||
|
requestMedia,
|
||||||
|
onRequested,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const isDataLoaded = defaultService && defaultServiceDetails && users;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
opacity: overlayOpacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
transform: [{ translateY: sheetTranslateY }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusGuideView
|
||||||
|
autoFocus
|
||||||
|
trapFocusUp
|
||||||
|
trapFocusDown
|
||||||
|
trapFocusLeft
|
||||||
|
trapFocusRight
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 44,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isDataLoaded ? (
|
||||||
|
<ScrollView
|
||||||
|
style={{ maxHeight: 320, overflow: "visible" }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={{
|
||||||
|
height: 200,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "rgba(255,255,255,0.5)" }}>
|
||||||
|
{t("common.loading")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRequest}
|
||||||
|
variant='secondary'
|
||||||
|
disabled={!isDataLoaded}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='add'
|
||||||
|
size={22}
|
||||||
|
color='#FFFFFF'
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
</View>
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</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")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
components/jellyseerr/tv/TVRequestOptionRow.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface TVRequestOptionRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.02,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(255,255,255,0.15)"
|
||||||
|
: "rgba(255,255,255,0.05)",
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: focused
|
||||||
|
? "rgba(255,255,255,0.3)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={18}
|
||||||
|
color={focused ? "#FFFFFF" : "rgba(255,255,255,0.5)"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
components/jellyseerr/tv/TVToggleOptionRow.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
interface ToggleItem {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TVToggleChipProps {
|
||||||
|
item: ToggleItem;
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.08,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onToggle(item.id)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: item.selected
|
||||||
|
? "rgba(255,255,255,0.25)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: item.selected
|
||||||
|
? "rgba(255,255,255,0.4)"
|
||||||
|
: "rgba(255,255,255,0.15)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: item.selected || focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVToggleOptionRowProps {
|
||||||
|
label: string;
|
||||||
|
items: ToggleItem[];
|
||||||
|
onToggle: (id: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
onToggle,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ gap: 10, paddingVertical: 12 }}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TVToggleChip
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onToggle={onToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
components/jellyseerr/tv/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { TVJellyseerrPage } from "./TVJellyseerrPage";
|
||||||
|
export { TVRequestModal } from "./TVRequestModal";
|
||||||
|
export { TVRequestOptionRow } from "./TVRequestOptionRow";
|
||||||
|
export { TVToggleOptionRow } from "./TVToggleOptionRow";
|
||||||
109
components/library/Libraries.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, StyleSheet, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export const Libraries: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
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, api, queryClient, user?.Id]);
|
||||||
|
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!libraries)
|
||||||
|
return (
|
||||||
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("library.no_libraries_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
extraData={settings}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: Platform.OS === "android" ? 17 : 0,
|
||||||
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
paddingLeft: insets.left + 17,
|
||||||
|
paddingRight: insets.right + 17,
|
||||||
|
}}
|
||||||
|
data={libraries}
|
||||||
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
ItemSeparatorComponent={() =>
|
||||||
|
settings?.libraryOptions?.display === "row" ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: StyleSheet.hairlineWidth,
|
||||||
|
}}
|
||||||
|
className='bg-neutral-800 mx-2 my-4'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='h-4' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
395
components/library/TVLibraries.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi, getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Easing, FlatList, Pressable, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 80;
|
||||||
|
const CARD_HEIGHT = 220;
|
||||||
|
const CARD_GAP = 24;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
const icons: Record<CollectionType, IconName> = {
|
||||||
|
movies: "film",
|
||||||
|
tvshows: "tv",
|
||||||
|
music: "musical-notes",
|
||||||
|
books: "book",
|
||||||
|
homevideos: "videocam",
|
||||||
|
boxsets: "albums",
|
||||||
|
playlists: "list",
|
||||||
|
folders: "folder",
|
||||||
|
livetv: "tv",
|
||||||
|
musicvideos: "musical-notes",
|
||||||
|
photos: "images",
|
||||||
|
trailers: "videocam",
|
||||||
|
unknown: "help-circle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface LibraryWithPreview extends BaseItemDto {
|
||||||
|
previewItems?: BaseItemDto[];
|
||||||
|
itemCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVLibraryRow: React.FC<{
|
||||||
|
library: LibraryWithPreview;
|
||||||
|
isFirst: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ library, isFirst, onPress }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const opacity = useRef(new Animated.Value(0.7)).current;
|
||||||
|
|
||||||
|
const animateTo = (toScale: number, toOpacity: number) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: toScale,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(opacity, {
|
||||||
|
toValue: toOpacity,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
// Try to get backdrop from library or first preview item
|
||||||
|
if (library.previewItems?.[0]) {
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: library.previewItems[0],
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: library,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, library]);
|
||||||
|
|
||||||
|
const iconName = icons[library.CollectionType!] || "folder";
|
||||||
|
|
||||||
|
const itemTypeName = useMemo(() => {
|
||||||
|
if (library.CollectionType === "movies")
|
||||||
|
return t("library.item_types.movies");
|
||||||
|
if (library.CollectionType === "tvshows")
|
||||||
|
return t("library.item_types.series");
|
||||||
|
if (library.CollectionType === "boxsets")
|
||||||
|
return t("library.item_types.boxsets");
|
||||||
|
if (library.CollectionType === "music")
|
||||||
|
return t("library.item_types.items");
|
||||||
|
return t("library.item_types.items");
|
||||||
|
}, [library.CollectionType, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.02, 1);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1, 0.7);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
opacity,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: focused ? 4 : 0,
|
||||||
|
borderColor: "#FFFFFF",
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.3 : 0,
|
||||||
|
shadowRadius: focused ? 30 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
{backdropUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: backdropUrl }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gradient Overlay */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.8)"]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon Container */}
|
||||||
|
<BlurView
|
||||||
|
intensity={60}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={iconName} size={40} color='#FFFFFF' />
|
||||||
|
</BlurView>
|
||||||
|
|
||||||
|
{/* Text Content */}
|
||||||
|
<View style={{ marginLeft: 24, flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "700",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{library.itemCount !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
marginTop: 4,
|
||||||
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.itemCount} {itemTypeName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Arrow Indicator */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
opacity: focused ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-forward' size={32} color='#FFFFFF' />
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVLibraries: React.FC = () => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const libraries = useMemo(
|
||||||
|
() =>
|
||||||
|
userViews
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "books")
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
|
.filter((l) => l.CollectionType !== "playlists") || [],
|
||||||
|
[userViews, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch item counts and preview items for each library
|
||||||
|
const { data: librariesWithData, isLoading: dataLoading } = useQuery({
|
||||||
|
queryKey: ["library-data", libraries.map((l) => l.Id).join(",")],
|
||||||
|
queryFn: async () => {
|
||||||
|
const results: LibraryWithPreview[] = await Promise.all(
|
||||||
|
libraries.map(async (library) => {
|
||||||
|
let itemType: string | undefined;
|
||||||
|
if (library.CollectionType === "movies") itemType = "Movie";
|
||||||
|
else if (library.CollectionType === "tvshows") itemType = "Series";
|
||||||
|
else if (library.CollectionType === "boxsets") itemType = "BoxSet";
|
||||||
|
|
||||||
|
// Fetch count
|
||||||
|
const countResponse = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
|
limit: 0,
|
||||||
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch preview items with backdrops
|
||||||
|
const previewResponse = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
|
limit: 1,
|
||||||
|
sortBy: ["Random"],
|
||||||
|
includeItemTypes: itemType ? [itemType as any] : undefined,
|
||||||
|
imageTypes: ["Backdrop"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...library,
|
||||||
|
itemCount: countResponse.data.TotalRecordCount,
|
||||||
|
previewItems: previewResponse.data.Items || [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && libraries.length > 0,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLibraryPress = useCallback(
|
||||||
|
(library: BaseItemDto) => {
|
||||||
|
if (library.CollectionType === "music") {
|
||||||
|
router.push({
|
||||||
|
pathname: `/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions`,
|
||||||
|
params: { libraryId: library.Id! },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: { libraryId: library.Id! },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: LibraryWithPreview; index: number }) => (
|
||||||
|
<View style={{ marginBottom: CARD_GAP, paddingHorizontal: 8 }}>
|
||||||
|
<TVLibraryRow
|
||||||
|
library={item}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onPress={() => handleLibraryPress(item)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleLibraryPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLoading = viewsLoading || dataLoading;
|
||||||
|
const displayLibraries = librariesWithData || libraries;
|
||||||
|
|
||||||
|
if (isLoading && libraries.length === 0) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!displayLibraries || displayLibraries.length === 0) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
|
{t("library.no_libraries_found")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top + 80,
|
||||||
|
paddingBottom: insets.bottom + 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FlatList
|
||||||
|
data={displayLibraries}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 40,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
components/library/TVLibraryCard.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemKind,
|
||||||
|
CollectionType,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_LIBRARY_CARD_WIDTH = 280;
|
||||||
|
export const TV_LIBRARY_CARD_HEIGHT = 180;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
library: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IconName = React.ComponentProps<typeof Ionicons>["name"];
|
||||||
|
|
||||||
|
const icons: Record<CollectionType, IconName> = {
|
||||||
|
movies: "film",
|
||||||
|
tvshows: "tv",
|
||||||
|
music: "musical-notes",
|
||||||
|
books: "book",
|
||||||
|
homevideos: "videocam",
|
||||||
|
boxsets: "albums",
|
||||||
|
playlists: "list",
|
||||||
|
folders: "folder",
|
||||||
|
livetv: "tv",
|
||||||
|
musicvideos: "musical-notes",
|
||||||
|
photos: "images",
|
||||||
|
trailers: "videocam",
|
||||||
|
unknown: "help-circle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
const url = useMemo(
|
||||||
|
() =>
|
||||||
|
getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: library,
|
||||||
|
}),
|
||||||
|
[api, library],
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemType = useMemo(() => {
|
||||||
|
let _itemType: BaseItemKind | undefined;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
_itemType = "Movie";
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
_itemType = "Series";
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
_itemType = "BoxSet";
|
||||||
|
} else if (library.CollectionType === "homevideos") {
|
||||||
|
_itemType = "Video";
|
||||||
|
} else if (library.CollectionType === "musicvideos") {
|
||||||
|
_itemType = "MusicVideo";
|
||||||
|
}
|
||||||
|
|
||||||
|
return _itemType;
|
||||||
|
}, [library.CollectionType]);
|
||||||
|
|
||||||
|
const itemTypeName = useMemo(() => {
|
||||||
|
let nameStr: string;
|
||||||
|
|
||||||
|
if (library.CollectionType === "movies") {
|
||||||
|
nameStr = t("library.item_types.movies");
|
||||||
|
} else if (library.CollectionType === "tvshows") {
|
||||||
|
nameStr = t("library.item_types.series");
|
||||||
|
} else if (library.CollectionType === "boxsets") {
|
||||||
|
nameStr = t("library.item_types.boxsets");
|
||||||
|
} else {
|
||||||
|
nameStr = t("library.item_types.items");
|
||||||
|
}
|
||||||
|
|
||||||
|
return nameStr;
|
||||||
|
}, [library.CollectionType, t]);
|
||||||
|
|
||||||
|
const { data: itemsCount } = useQuery({
|
||||||
|
queryKey: ["library-count", library.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getItemsApi(api!).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: library.Id,
|
||||||
|
recursive: true,
|
||||||
|
limit: 0,
|
||||||
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
|
});
|
||||||
|
return response.data.TotalRecordCount;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id && !!library.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconName = icons[library.CollectionType!] || "folder";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_LIBRARY_CARD_WIDTH,
|
||||||
|
height: TV_LIBRARY_CARD_HEIGHT,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: url ? "rgba(0, 0, 0, 0.6)" : "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={iconName} size={48} color='#e5e5e5' />
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{itemsCount !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{itemsCount} {itemTypeName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
456
components/login/Login.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Keyboard,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Switch,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import type {
|
||||||
|
AccountSecurityType,
|
||||||
|
SavedServer,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
const CredentialsSchema = z.object({
|
||||||
|
username: z.string().min(1, t("login.username_required")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (_apiUrl) {
|
||||||
|
await setServer({
|
||||||
|
address: _apiUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_username && _password) {
|
||||||
|
setCredentials({ username: _username, password: _password });
|
||||||
|
login(_username, _password);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: serverName,
|
||||||
|
headerLeft: () =>
|
||||||
|
api?.basePath ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeServer();
|
||||||
|
}}
|
||||||
|
className='flex flex-row items-center pr-2 pl-1'
|
||||||
|
>
|
||||||
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
|
<Text className=' ml-1 text-purple-600'>
|
||||||
|
{t("login.change_server")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null,
|
||||||
|
});
|
||||||
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
if (saveAccount) {
|
||||||
|
setPendingLogin({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performLogin = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password, serverName, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAccountConfirm = async (
|
||||||
|
securityType: AccountSecurityType,
|
||||||
|
pinCode?: string,
|
||||||
|
) => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
if (pendingLogin) {
|
||||||
|
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||||
|
saveAccount: true,
|
||||||
|
securityType,
|
||||||
|
pinCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickLoginWithSavedCredential = async (
|
||||||
|
serverUrl: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
|
await loginWithSavedCredential(serverUrl, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordLogin = async (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) => {
|
||||||
|
await loginWithPassword(serverUrl, username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccount = (server: SavedServer) => {
|
||||||
|
setServer({ address: server.address });
|
||||||
|
if (server.name) {
|
||||||
|
setServerName(server.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
components/login/TVAccountCard.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Easing, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import type { SavedServerAccount } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVAccountCardProps {
|
||||||
|
account: SavedServerAccount;
|
||||||
|
onPress: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVAccountCard: React.FC<TVAccountCardProps> = ({
|
||||||
|
account,
|
||||||
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.03 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(glowOpacity, {
|
||||||
|
toValue: focused ? 0.6 : 0,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => {
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "pin":
|
||||||
|
return "keypad";
|
||||||
|
case "password":
|
||||||
|
return "lock-closed";
|
||||||
|
default:
|
||||||
|
return "key";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityText = (): string => {
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "pin":
|
||||||
|
return t("save_account.pin_code");
|
||||||
|
case "password":
|
||||||
|
return t("save_account.password");
|
||||||
|
default:
|
||||||
|
return t("save_account.no_protection");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
{ shadowOpacity: glowOpacity },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: isFocused ? "#2a2a2a" : "#262626",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
backgroundColor: "#404040",
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginRight: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='person' size={28} color='white' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Account Info */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{account.username}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getSecurityText()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Security Icon */}
|
||||||
|
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
components/login/TVInput.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface TVInputProps extends TextInputProps {
|
||||||
|
label?: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVInput: React.FC<TVInputProps> = ({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayPlaceholder = placeholder || label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={displayPlaceholder}
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
height: 68,
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
721
components/login/TVLogin.tsx
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVInput } from "@/components/login/TVInput";
|
||||||
|
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||||
|
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||||
|
import {
|
||||||
|
TVPreviousServersList,
|
||||||
|
TVServerActionSheet,
|
||||||
|
} from "@/components/login/TVPreviousServersList";
|
||||||
|
import { TVSaveAccountModal } from "@/components/login/TVSaveAccountModal";
|
||||||
|
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
type AccountSecurityType,
|
||||||
|
removeServerFromList,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
const CredentialsSchema = z.object({
|
||||||
|
username: z.string().min(1, t("login.username_required")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false }) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.05 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
}}
|
||||||
|
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: isFocused
|
||||||
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
|
: "transparent",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? Colors.primary : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={28}
|
||||||
|
color={isFocused ? "#FFFFFF" : Colors.primary}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: isFocused ? "#FFFFFF" : Colors.primary,
|
||||||
|
fontSize: 20,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVLogin: 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);
|
||||||
|
|
||||||
|
// PIN/Password entry for saved accounts
|
||||||
|
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||||
|
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [selectedAccount, setSelectedAccount] =
|
||||||
|
useState<SavedServerAccount | null>(null);
|
||||||
|
|
||||||
|
// Server action sheet state
|
||||||
|
const [showServerActionSheet, setShowServerActionSheet] = useState(false);
|
||||||
|
const [actionSheetServer, setActionSheetServer] =
|
||||||
|
useState<SavedServer | null>(null);
|
||||||
|
const [loginTriggerServer, setLoginTriggerServer] =
|
||||||
|
useState<SavedServer | null>(null);
|
||||||
|
const [actionSheetKey, setActionSheetKey] = useState(0);
|
||||||
|
|
||||||
|
// Track if any modal is open to disable background focus
|
||||||
|
const isAnyModalOpen =
|
||||||
|
showSaveModal ||
|
||||||
|
pinModalVisible ||
|
||||||
|
passwordModalVisible ||
|
||||||
|
showServerActionSheet;
|
||||||
|
|
||||||
|
// Auto login from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (_apiUrl) {
|
||||||
|
await setServer({ address: _apiUrl });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_username && _password) {
|
||||||
|
setCredentials({ username: _username, password: _password });
|
||||||
|
login(_username, _password);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerTitle: serverName,
|
||||||
|
headerShown: false,
|
||||||
|
});
|
||||||
|
}, [serverName, navigation]);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
if (saveAccount) {
|
||||||
|
setPendingLogin({
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
});
|
||||||
|
setShowSaveModal(true);
|
||||||
|
} else {
|
||||||
|
await performLogin(credentials.username, credentials.password);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performLogin = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options?: {
|
||||||
|
saveAccount?: boolean;
|
||||||
|
securityType?: AccountSecurityType;
|
||||||
|
pinCode?: string;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(username, password, serverName, options);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAccountConfirm = async (
|
||||||
|
securityType: AccountSecurityType,
|
||||||
|
pinCode?: string,
|
||||||
|
) => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
if (pendingLogin) {
|
||||||
|
await performLogin(pendingLogin.username, pendingLogin.password, {
|
||||||
|
saveAccount: true,
|
||||||
|
securityType,
|
||||||
|
pinCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickLoginWithSavedCredential = async (
|
||||||
|
serverUrl: string,
|
||||||
|
userId: string,
|
||||||
|
) => {
|
||||||
|
await loginWithSavedCredential(serverUrl, userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordLogin = async (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) => {
|
||||||
|
await loginWithPassword(serverUrl, username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAccount = (server: SavedServer) => {
|
||||||
|
setServer({ address: server.address });
|
||||||
|
if (server.name) {
|
||||||
|
setServerName(server.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinRequired = (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPinModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordRequired = (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setSelectedAccount(account);
|
||||||
|
setPasswordModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSuccess = async () => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
await handleQuickLoginWithSavedCredential(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (password: string) => {
|
||||||
|
if (selectedServer && selectedAccount) {
|
||||||
|
await handlePasswordLogin(
|
||||||
|
selectedServer.address,
|
||||||
|
selectedAccount.username,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPIN = async () => {
|
||||||
|
if (selectedServer) {
|
||||||
|
setSelectedServer(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setPinModalVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server action sheet handlers
|
||||||
|
const handleServerAction = (server: SavedServer) => {
|
||||||
|
setActionSheetServer(server);
|
||||||
|
setActionSheetKey((k) => k + 1); // Force remount to reset focus
|
||||||
|
setShowServerActionSheet(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerActionLogin = () => {
|
||||||
|
setShowServerActionSheet(false);
|
||||||
|
if (actionSheetServer) {
|
||||||
|
// Trigger the login flow in TVPreviousServersList
|
||||||
|
setLoginTriggerServer(actionSheetServer);
|
||||||
|
// Reset the trigger after a tick to allow re-triggering the same server
|
||||||
|
setTimeout(() => setLoginTriggerServer(null), 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerActionDelete = () => {
|
||||||
|
if (!actionSheetServer) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("server.remove_server"),
|
||||||
|
t("server.remove_server_description", {
|
||||||
|
server: actionSheetServer.name || actionSheetServer.address,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
style: "cancel",
|
||||||
|
onPress: () => setShowServerActionSheet(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await removeServerFromList(actionSheetServer.address);
|
||||||
|
setShowServerActionSheet(false);
|
||||||
|
setActionSheetServer(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
|
url = url.trim().replace(/\/$/, "");
|
||||||
|
console.log("[TVLogin] handleConnect called with:", url);
|
||||||
|
try {
|
||||||
|
const result = await checkUrl(url);
|
||||||
|
console.log("[TVLogin] checkUrl result:", result);
|
||||||
|
if (result === undefined) {
|
||||||
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.could_not_connect_to_server"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("[TVLogin] Calling setServer with:", result);
|
||||||
|
await setServer({ address: result });
|
||||||
|
console.log("[TVLogin] setServer completed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[TVLogin] Error in handleConnect:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log("[TVLogin] Render - api?.basePath:", api?.basePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{api?.basePath ? (
|
||||||
|
// ==================== CREDENTIALS SCREEN ====================
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Back Button */}
|
||||||
|
<TVBackButton
|
||||||
|
onPress={() => removeServer()}
|
||||||
|
label={t("login.change_server")}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serverName ? (
|
||||||
|
<>
|
||||||
|
{`${t("login.login_to_title")} `}
|
||||||
|
<Text style={{ color: Colors.primary }}>{serverName}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("login.login_title")
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{api.basePath}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Username Input - extra padding for focus scale */}
|
||||||
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("login.username_placeholder")}
|
||||||
|
value={credentials.username}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, username: text }))
|
||||||
|
}
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
textContentType='username'
|
||||||
|
returnKeyType='next'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("login.password_placeholder")}
|
||||||
|
value={credentials.password}
|
||||||
|
onChangeText={(text) =>
|
||||||
|
setCredentials((prev) => ({ ...prev, password: text }))
|
||||||
|
}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='password'
|
||||||
|
returnKeyType='done'
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Account Toggle */}
|
||||||
|
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||||
|
<TVSaveAccountToggle
|
||||||
|
value={saveAccount}
|
||||||
|
onValueChange={setSaveAccount}
|
||||||
|
label={t("save_account.save_for_later")}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Login Button */}
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!credentials.username.trim() || loading}
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Quick Connect Button */}
|
||||||
|
<Button
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
color='black'
|
||||||
|
className='bg-neutral-800 border border-neutral-700'
|
||||||
|
>
|
||||||
|
{t("login.quick_connect")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
// ==================== SERVER SELECTION SCREEN ====================
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingVertical: 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
paddingHorizontal: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/icon-tvos.png")}
|
||||||
|
style={{ width: 150, height: 150 }}
|
||||||
|
contentFit='contain'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Streamyfin
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Server URL Input - extra padding for focus scale */}
|
||||||
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
|
<TVInput
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
value={serverURL}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
keyboardType='url'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
returnKeyType='done'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Connect Button */}
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
onPress={() => handleConnect(serverURL)}
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck || !serverURL.trim()}
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Previous Servers */}
|
||||||
|
<View style={{ paddingHorizontal: 8 }}>
|
||||||
|
<TVPreviousServersList
|
||||||
|
onServerSelect={(s) => handleConnect(s.address)}
|
||||||
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
onPasswordLogin={handlePasswordLogin}
|
||||||
|
onAddAccount={handleAddAccount}
|
||||||
|
onPinRequired={handlePinRequired}
|
||||||
|
onPasswordRequired={handlePasswordRequired}
|
||||||
|
onServerAction={handleServerAction}
|
||||||
|
loginServerOverride={loginTriggerServer}
|
||||||
|
disabled={isAnyModalOpen}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Account Modal */}
|
||||||
|
<TVSaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* PIN Entry Modal */}
|
||||||
|
<TVPINEntryModal
|
||||||
|
visible={pinModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPinModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSuccess={handlePinSuccess}
|
||||||
|
onForgotPIN={handleForgotPIN}
|
||||||
|
serverUrl={selectedServer?.address || ""}
|
||||||
|
userId={selectedAccount?.userId || ""}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Entry Modal */}
|
||||||
|
<TVPasswordEntryModal
|
||||||
|
visible={passwordModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setPasswordModalVisible(false);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
setSelectedServer(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handlePasswordSubmit}
|
||||||
|
username={selectedAccount?.username || ""}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Server Action Sheet */}
|
||||||
|
<TVServerActionSheet
|
||||||
|
key={actionSheetKey}
|
||||||
|
visible={showServerActionSheet}
|
||||||
|
server={actionSheetServer}
|
||||||
|
onLogin={handleServerActionLogin}
|
||||||
|
onDelete={handleServerActionDelete}
|
||||||
|
onClose={() => setShowServerActionSheet(false)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
327
components/login/TVPINEntryModal.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVPINEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onForgotPIN?: () => void;
|
||||||
|
serverUrl: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgot PIN Button
|
||||||
|
const TVForgotPINButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
|
: "transparent",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#d8b4fe" : "#a855f7",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVPINEntryModal: React.FC<TVPINEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onForgotPIN,
|
||||||
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setPinCode("");
|
||||||
|
setError(null);
|
||||||
|
setIsVerifying(false);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && isReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pinInputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, isReady]);
|
||||||
|
|
||||||
|
const shake = () => {
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: -15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 15,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnimation, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 50,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinChange = async (value: string) => {
|
||||||
|
setPinCode(value);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Auto-verify when 4 digits entered
|
||||||
|
if (value.length === 4) {
|
||||||
|
setIsVerifying(true);
|
||||||
|
try {
|
||||||
|
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
||||||
|
if (isValid) {
|
||||||
|
onSuccess();
|
||||||
|
setPinCode("");
|
||||||
|
} else {
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t("pin.invalid_pin"));
|
||||||
|
shake();
|
||||||
|
setPinCode("");
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForgotPIN = () => {
|
||||||
|
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.continue"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => {
|
||||||
|
onClose();
|
||||||
|
onForgotPIN?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) 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}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("pin.enter_pin")}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t("pin.enter_pin_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* PIN Input */}
|
||||||
|
{isReady && (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.pinContainer,
|
||||||
|
{ transform: [{ translateX: shakeAnimation }] },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TVPinInput
|
||||||
|
ref={pinInputRef}
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={handlePinChange}
|
||||||
|
length={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
{isVerifying && (
|
||||||
|
<Text style={styles.verifyingText}>
|
||||||
|
{t("common.verifying")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Forgot PIN */}
|
||||||
|
{isReady && onForgotPIN && (
|
||||||
|
<View style={styles.forgotContainer}>
|
||||||
|
<TVForgotPINButton
|
||||||
|
onPress={handleForgotPIN}
|
||||||
|
label={t("pin.forgot_pin")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
pinContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
verifyingText: {
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
forgotContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
337
components/login/TVPasswordEntryModal.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv";
|
||||||
|
|
||||||
|
interface TVPasswordEntryModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (password: string) => Promise<void>;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV Submit Button
|
||||||
|
const TVSubmitButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ onPress, label, loading = false, disabled = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#a855f7"
|
||||||
|
: isDisabled
|
||||||
|
? "#4a4a4a"
|
||||||
|
: "#7c3aed",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 120,
|
||||||
|
opacity: isDisabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size='small' color='#fff' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name='log-in-outline' size={20} color='#fff' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV Focusable Password Input
|
||||||
|
const TVPasswordInput: React.FC<{
|
||||||
|
value: string;
|
||||||
|
onChangeText: (text: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
onSubmitEditing: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
onSubmitEditing,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => inputRef.current?.focus()}
|
||||||
|
onFocus={() => {
|
||||||
|
handleFocus();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: "#1F2937",
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: focused ? "#6366F1" : "#374151",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor='#6B7280'
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 18,
|
||||||
|
}}
|
||||||
|
onSubmitEditing={onSubmitEditing}
|
||||||
|
returnKeyType='done'
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVPasswordEntryModal: React.FC<TVPasswordEntryModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setPassword("");
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!password) {
|
||||||
|
setError(t("password.enter_password"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(password);
|
||||||
|
setPassword("");
|
||||||
|
} catch {
|
||||||
|
setError(t("password.invalid_password"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) 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}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("password.enter_password")}</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
{t("password.enter_password_for", { username })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>{t("login.password")}</Text>
|
||||||
|
<TVPasswordInput
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t("login.password")}
|
||||||
|
onSubmitEditing={handleSubmit}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TVSubmitButton
|
||||||
|
onPress={handleSubmit}
|
||||||
|
label={t("login.login")}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={!password}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
512
components/login/TVPreviousServersList.tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import {
|
||||||
|
deleteAccountCredential,
|
||||||
|
getPreviousServers,
|
||||||
|
type SavedServer,
|
||||||
|
type SavedServerAccount,
|
||||||
|
} from "@/utils/secureCredentials";
|
||||||
|
import { TVAccountCard } from "./TVAccountCard";
|
||||||
|
import { TVServerCard } from "./TVServerCard";
|
||||||
|
|
||||||
|
// Action card for server action sheet (Apple TV style)
|
||||||
|
const TVServerActionCard: React.FC<{
|
||||||
|
label: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
const isDestructive = variant === "destructive";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
width: 180,
|
||||||
|
height: 90,
|
||||||
|
backgroundColor: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
: isDestructive
|
||||||
|
? "rgba(239, 68, 68, 0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={icon}
|
||||||
|
size={28}
|
||||||
|
color={
|
||||||
|
focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused
|
||||||
|
? isDestructive
|
||||||
|
? "#fff"
|
||||||
|
: "#000"
|
||||||
|
: isDestructive
|
||||||
|
? "#ef4444"
|
||||||
|
: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server action sheet component (bottom sheet with horizontal scrolling)
|
||||||
|
const TVServerActionSheet: React.FC<{
|
||||||
|
visible: boolean;
|
||||||
|
server: SavedServer | null;
|
||||||
|
onLogin: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = ({ visible, server, onLogin, onDelete, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!server) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType='fade'
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{server.name || server.address}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Horizontal options */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVServerActionCard
|
||||||
|
label={t("common.login")}
|
||||||
|
icon='log-in-outline'
|
||||||
|
hasTVPreferredFocus
|
||||||
|
onPress={onLogin}
|
||||||
|
/>
|
||||||
|
<TVServerActionCard
|
||||||
|
label={t("common.delete")}
|
||||||
|
icon='trash-outline'
|
||||||
|
variant='destructive'
|
||||||
|
onPress={onDelete}
|
||||||
|
/>
|
||||||
|
<TVServerActionCard
|
||||||
|
label={t("common.cancel")}
|
||||||
|
icon='close-outline'
|
||||||
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVPreviousServersListProps {
|
||||||
|
onServerSelect: (server: SavedServer) => void;
|
||||||
|
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
|
||||||
|
onPasswordLogin?: (
|
||||||
|
serverUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
onAddAccount?: (server: SavedServer) => void;
|
||||||
|
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
|
||||||
|
onPasswordRequired?: (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => void;
|
||||||
|
// Called when server is pressed to show action sheet (handled by parent)
|
||||||
|
onServerAction?: (server: SavedServer) => void;
|
||||||
|
// Called by parent when "Login" is selected from action sheet
|
||||||
|
loginServerOverride?: SavedServer | null;
|
||||||
|
// Disable all focusable elements (when a modal is open)
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the action sheet for use in parent components
|
||||||
|
export { TVServerActionSheet };
|
||||||
|
|
||||||
|
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||||
|
onServerSelect,
|
||||||
|
onQuickLogin,
|
||||||
|
onAddAccount,
|
||||||
|
onPinRequired,
|
||||||
|
onPasswordRequired,
|
||||||
|
onServerAction,
|
||||||
|
loginServerOverride,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [_previousServers, setPreviousServers] =
|
||||||
|
useMMKVString("previousServers");
|
||||||
|
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
||||||
|
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [showAccountsModal, setShowAccountsModal] = useState(false);
|
||||||
|
|
||||||
|
const previousServers = useMemo(() => {
|
||||||
|
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||||
|
}, [_previousServers]);
|
||||||
|
|
||||||
|
// When parent triggers login via loginServerOverride, execute the login flow
|
||||||
|
useEffect(() => {
|
||||||
|
if (loginServerOverride) {
|
||||||
|
const accountCount = loginServerOverride.accounts?.length || 0;
|
||||||
|
|
||||||
|
if (accountCount === 0) {
|
||||||
|
onServerSelect(loginServerOverride);
|
||||||
|
} else if (accountCount === 1) {
|
||||||
|
handleAccountLogin(
|
||||||
|
loginServerOverride,
|
||||||
|
loginServerOverride.accounts[0],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedServer(loginServerOverride);
|
||||||
|
setShowAccountsModal(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loginServerOverride]);
|
||||||
|
|
||||||
|
const refreshServers = () => {
|
||||||
|
const servers = getPreviousServers();
|
||||||
|
setPreviousServers(JSON.stringify(servers));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountLogin = async (
|
||||||
|
server: SavedServer,
|
||||||
|
account: SavedServerAccount,
|
||||||
|
) => {
|
||||||
|
setShowAccountsModal(false);
|
||||||
|
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "none":
|
||||||
|
if (onQuickLogin) {
|
||||||
|
setLoadingServer(server.address);
|
||||||
|
try {
|
||||||
|
await onQuickLogin(server.address, account.userId);
|
||||||
|
} catch {
|
||||||
|
Alert.alert(
|
||||||
|
t("server.session_expired"),
|
||||||
|
t("server.please_login_again"),
|
||||||
|
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingServer(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pin":
|
||||||
|
if (onPinRequired) {
|
||||||
|
onPinRequired(server, account);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "password":
|
||||||
|
if (onPasswordRequired) {
|
||||||
|
onPasswordRequired(server, account);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerPress = (server: SavedServer) => {
|
||||||
|
if (loadingServer) return;
|
||||||
|
|
||||||
|
// If onServerAction is provided, delegate to parent for action sheet handling
|
||||||
|
if (onServerAction) {
|
||||||
|
onServerAction(server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: direct login flow (for backwards compatibility)
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
if (accountCount === 0) {
|
||||||
|
onServerSelect(server);
|
||||||
|
} else if (accountCount === 1) {
|
||||||
|
handleAccountLogin(server, server.accounts[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedServer(server);
|
||||||
|
setShowAccountsModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServerSubtitle = (server: SavedServer): string | undefined => {
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
|
||||||
|
if (accountCount > 1) {
|
||||||
|
return t("server.accounts_count", { count: accountCount });
|
||||||
|
}
|
||||||
|
if (accountCount === 1) {
|
||||||
|
return `${server.accounts[0].username} • ${t("server.saved")}`;
|
||||||
|
}
|
||||||
|
return server.name ? server.address : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityIcon = (
|
||||||
|
server: SavedServer,
|
||||||
|
): keyof typeof Ionicons.glyphMap | null => {
|
||||||
|
const accountCount = server.accounts?.length || 0;
|
||||||
|
if (accountCount === 0) return null;
|
||||||
|
|
||||||
|
if (accountCount > 1) {
|
||||||
|
return "people";
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = server.accounts[0];
|
||||||
|
switch (account.securityType) {
|
||||||
|
case "pin":
|
||||||
|
return "keypad";
|
||||||
|
case "password":
|
||||||
|
return "lock-closed";
|
||||||
|
default:
|
||||||
|
return "key";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
t("server.remove_saved_login"),
|
||||||
|
t("server.remove_account_description", { username: account.username }),
|
||||||
|
[
|
||||||
|
{ text: t("common.cancel"), style: "cancel" },
|
||||||
|
{
|
||||||
|
text: t("common.remove"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteAccountCredential(
|
||||||
|
selectedServer.address,
|
||||||
|
account.userId,
|
||||||
|
);
|
||||||
|
refreshServers();
|
||||||
|
if (selectedServer.accounts.length <= 1) {
|
||||||
|
setShowAccountsModal(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.previous_servers")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
{previousServers.map((server) => (
|
||||||
|
<TVServerCard
|
||||||
|
key={server.address}
|
||||||
|
title={server.name || server.address}
|
||||||
|
subtitle={getServerSubtitle(server)}
|
||||||
|
securityIcon={getSecurityIcon(server)}
|
||||||
|
isLoading={loadingServer === server.address}
|
||||||
|
onPress={() => handleServerPress(server)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* TV Account Selection Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showAccountsModal}
|
||||||
|
transparent
|
||||||
|
animationType='fade'
|
||||||
|
onRequestClose={() => setShowAccountsModal(false)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 40,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.select_account")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedServer?.name || selectedServer?.address}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ gap: 12, marginBottom: 24 }}>
|
||||||
|
{selectedServer?.accounts.map((account, index) => (
|
||||||
|
<TVAccountCard
|
||||||
|
key={account.userId}
|
||||||
|
account={account}
|
||||||
|
onPress={() =>
|
||||||
|
selectedServer &&
|
||||||
|
handleAccountLogin(selectedServer, account)
|
||||||
|
}
|
||||||
|
onLongPress={() => handleDeleteAccount(account)}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
setShowAccountsModal(false);
|
||||||
|
if (selectedServer && onAddAccount) {
|
||||||
|
onAddAccount(selectedServer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color='purple'
|
||||||
|
>
|
||||||
|
{t("server.add_account")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onPress={() => setShowAccountsModal(false)}
|
||||||
|
color='black'
|
||||||
|
className='bg-neutral-800'
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
435
components/login/TVSaveAccountModal.tsx
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useEffect, 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 { TVPinInput, type TVPinInputRef } from "@/components/inputs/TVPinInput";
|
||||||
|
import { TVOptionCard, useTVFocusAnimation } from "@/components/tv";
|
||||||
|
import type { AccountSecurityType } from "@/utils/secureCredentials";
|
||||||
|
|
||||||
|
interface TVSaveAccountModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityOption {
|
||||||
|
type: AccountSecurityType;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECURITY_OPTIONS: SecurityOption[] = [
|
||||||
|
{
|
||||||
|
type: "none",
|
||||||
|
titleKey: "save_account.no_protection",
|
||||||
|
descriptionKey: "save_account.no_protection_desc",
|
||||||
|
icon: "flash-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "pin",
|
||||||
|
titleKey: "save_account.pin_code",
|
||||||
|
descriptionKey: "save_account.pin_code_desc",
|
||||||
|
icon: "keypad-outline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "password",
|
||||||
|
titleKey: "save_account.password",
|
||||||
|
descriptionKey: "save_account.password_desc",
|
||||||
|
icon: "lock-closed-outline",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Custom Save Button with TV focus
|
||||||
|
const TVSaveButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, disabled = false, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#a855f7"
|
||||||
|
: disabled
|
||||||
|
? "#4a4a4a"
|
||||||
|
: "#7c3aed",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name='checkmark' size={20} color='#fff' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Back Button for PIN step
|
||||||
|
const TVBackButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}> = ({ onPress, label, hasTVPreferredFocus = false }) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-back'
|
||||||
|
size={20}
|
||||||
|
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSaveAccountModal: React.FC<TVSaveAccountModalProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
username,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [step, setStep] = useState<"select" | "pin">("select");
|
||||||
|
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||||
|
const [pinCode, setPinCode] = useState("");
|
||||||
|
const [pinError, setPinError] = useState<string | null>(null);
|
||||||
|
const pinInputRef = useRef<TVPinInputRef>(null);
|
||||||
|
|
||||||
|
// Use useState for focus tracking (per TV focus guide)
|
||||||
|
const [firstCardRef, setFirstCardRef] = useState<View | null>(null);
|
||||||
|
|
||||||
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// Reset state when opening
|
||||||
|
setStep("select");
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}, [visible, overlayOpacity, sheetTranslateY]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Focus the first card when ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef && step === "select") {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady, firstCardRef, step]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "pin" && isReady) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pinInputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [step, isReady]);
|
||||||
|
|
||||||
|
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
if (type === "pin") {
|
||||||
|
setStep("pin");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
} else {
|
||||||
|
// For "none" or "password", save immediately
|
||||||
|
onSave(type);
|
||||||
|
resetAndClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePinSave = () => {
|
||||||
|
if (pinCode.length !== 4) {
|
||||||
|
setPinError(t("pin.enter_4_digits"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave("pin", pinCode);
|
||||||
|
resetAndClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setStep("select");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAndClose = () => {
|
||||||
|
setStep("select");
|
||||||
|
setSelectedType("none");
|
||||||
|
setPinCode("");
|
||||||
|
setPinError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) 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}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{t("save_account.title")}</Text>
|
||||||
|
<Text style={styles.subtitle}>{username}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{step === "select" ? (
|
||||||
|
// Security selection step
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>
|
||||||
|
{t("save_account.security_option")}
|
||||||
|
</Text>
|
||||||
|
{isReady && (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{SECURITY_OPTIONS.map((option, index) => (
|
||||||
|
<TVOptionCard
|
||||||
|
key={option.type}
|
||||||
|
ref={index === 0 ? setFirstCardRef : undefined}
|
||||||
|
label={t(option.titleKey)}
|
||||||
|
sublabel={t(option.descriptionKey)}
|
||||||
|
selected={selectedType === option.type}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
onPress={() => handleOptionSelect(option.type)}
|
||||||
|
width={220}
|
||||||
|
height={100}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// PIN entry step
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionTitle}>{t("pin.setup_pin")}</Text>
|
||||||
|
<View style={styles.pinContainer}>
|
||||||
|
<TVPinInput
|
||||||
|
ref={pinInputRef}
|
||||||
|
value={pinCode}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPinCode(text);
|
||||||
|
setPinError(null);
|
||||||
|
}}
|
||||||
|
length={4}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{pinError && <Text style={styles.errorText}>{pinError}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<TVBackButton
|
||||||
|
onPress={handleBack}
|
||||||
|
label={t("common.back")}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
/>
|
||||||
|
<TVSaveButton
|
||||||
|
onPress={handlePinSave}
|
||||||
|
label={t("save_account.save_button")}
|
||||||
|
disabled={pinCode.length !== 4}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TVFocusGuideView>
|
||||||
|
</BlurView>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
sheetContainer: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
overflow: "visible",
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
marginTop: 20,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
pinContainer: {
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#ef4444",
|
||||||
|
fontSize: 14,
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
119
components/login/TVSaveAccountToggle.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Animated, Easing, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface TVSaveAccountToggleProps {
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
label: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
label,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(glowOpacity, {
|
||||||
|
toValue: focused ? 0.6 : 0,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onValueChange(!value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
{ shadowOpacity: glowOpacity },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
pointerEvents='none'
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 17,
|
||||||
|
backgroundColor: value ? Colors.primary : "#3f3f46",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: "white",
|
||||||
|
alignSelf: value ? "flex-end" : "flex-start",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
components/login/TVServerCard.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
|
interface TVServerCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
securityIcon?: keyof typeof Ionicons.glyphMap | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
securityIcon,
|
||||||
|
isLoading,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
const animateFocus = (focused: boolean) => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: focused ? 1.02 : 1,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(glowOpacity, {
|
||||||
|
toValue: focused ? 0.7 : 0,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isDisabled}
|
||||||
|
focusable={!isDisabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !isDisabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
{ shadowOpacity: glowOpacity },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ marginLeft: 16 }}>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
|
) : securityIcon ? (
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
|
<Ionicons
|
||||||
|
name={securityIcon}
|
||||||
|
size={20}
|
||||||
|
color={Colors.primary}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={24}
|
||||||
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-forward'
|
||||||
|
size={24}
|
||||||
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -243,7 +243,7 @@ export const MiniPlayerBar: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
||||||
{Platform.OS === "ios" ? (
|
{Platform.OS === "ios" && !Platform.isTV ? (
|
||||||
<GlassEffectView style={styles.blurContainer}>
|
<GlassEffectView style={styles.blurContainer}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
617
components/persons/TVActorPage.tsx
Normal file
@@ -0,0 +1,617 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useSegments } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 80;
|
||||||
|
const TOP_PADDING = 140;
|
||||||
|
const ACTOR_IMAGE_SIZE = 250;
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
// Focusable poster wrapper component for TV
|
||||||
|
const TVFocusablePoster: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (value: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
onFocus?.();
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
onBlur?.();
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#ffffff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVActorPageProps {
|
||||||
|
personId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
// Track which filmography item is currently focused for dynamic backdrop
|
||||||
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
|
|
||||||
|
// Fetch actor details
|
||||||
|
const { data: item, isLoading: isLoadingActor } = useQuery({
|
||||||
|
queryKey: ["item", personId],
|
||||||
|
queryFn: async () =>
|
||||||
|
await getUserItemData({
|
||||||
|
api,
|
||||||
|
userId: user?.Id,
|
||||||
|
itemId: personId,
|
||||||
|
}),
|
||||||
|
enabled: !!personId && !!api,
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch movies
|
||||||
|
const { data: movies = [], isLoading: isLoadingMovies } = useQuery({
|
||||||
|
queryKey: ["actor", "movies", personId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
personIds: [personId],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
|
includeItemTypes: ["Movie"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
||||||
|
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!personId && !!api && !!user?.Id,
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch series
|
||||||
|
const { data: series = [], isLoading: isLoadingSeries } = useQuery({
|
||||||
|
queryKey: ["actor", "series", personId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
personIds: [personId],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
sortOrder: ["Descending", "Descending", "Ascending"],
|
||||||
|
includeItemTypes: ["Series"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["ParentId", "PrimaryImageAspectRatio"],
|
||||||
|
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled: !!personId && !!api && !!user?.Id,
|
||||||
|
staleTime: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get backdrop URL from the currently focused filmography item
|
||||||
|
// Changes dynamically as user navigates through the list
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
// Use focused item if available, otherwise fall back to first movie or series
|
||||||
|
const itemForBackdrop = focusedItem ?? movies[0] ?? series[0];
|
||||||
|
if (!itemForBackdrop) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: itemForBackdrop,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, focusedItem, movies, series]);
|
||||||
|
|
||||||
|
// Crossfade animation for backdrop transitions
|
||||||
|
// Use two alternating layers for smooth crossfade
|
||||||
|
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(1)).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) {
|
||||||
|
// After animation completes, switch the active layer
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [backdropUrl]);
|
||||||
|
|
||||||
|
// Get actor image URL
|
||||||
|
const actorImageUrl = useMemo(() => {
|
||||||
|
if (!item?.Id || !api?.basePath) return null;
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillWidth=${ACTOR_IMAGE_SIZE * 2}&fillHeight=${ACTOR_IMAGE_SIZE * 2}&quality=90`;
|
||||||
|
}, [api?.basePath, item?.Id]);
|
||||||
|
|
||||||
|
// Handle filmography item press
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(filmItem: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(filmItem, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// List item layout
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render filmography item
|
||||||
|
const renderFilmographyItem = useCallback(
|
||||||
|
(
|
||||||
|
{ item: filmItem, index }: { item: BaseItemDto; index: number },
|
||||||
|
isFirstSection: boolean,
|
||||||
|
) => (
|
||||||
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(filmItem)}
|
||||||
|
onFocus={() => setFocusedItem(filmItem)}
|
||||||
|
hasTVPreferredFocus={isFirstSection && index === 0}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<MoviePoster item={filmItem} />
|
||||||
|
<View style={{ width: TV_POSTER_WIDTH, marginTop: 8 }}>
|
||||||
|
<ItemCardText item={filmItem} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleItemPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingActor) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item?.Id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Full-screen backdrop with crossfade - two alternating layers */}
|
||||||
|
<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'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
|
)}
|
||||||
|
</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'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.8)", "transparent"]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 0.6, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "60%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<ScrollView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: 60,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top section - Actor image + Info */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Circular actor image */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: ACTOR_IMAGE_SIZE,
|
||||||
|
height: ACTOR_IMAGE_SIZE,
|
||||||
|
borderRadius: ACTOR_IMAGE_SIZE / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginRight: 50,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: "rgba(255,255,255,0.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actorImageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: actorImageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='person'
|
||||||
|
size={80}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Info */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Actor name */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Production year / Birth year */}
|
||||||
|
{item.ProductionYear && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Biography */}
|
||||||
|
{item.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#D1D5DB",
|
||||||
|
lineHeight: 28,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Filmography sections */}
|
||||||
|
<View style={{ flex: 1, overflow: "visible" }}>
|
||||||
|
{/* Movies Section */}
|
||||||
|
{isLoadingMovies ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
movies.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.movies")}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={movies}
|
||||||
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
|
renderItem={(props) => renderFilmographyItem(props, true)}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={4}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Series Section */}
|
||||||
|
{isLoadingSeries ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
series.length > 0 && (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.shows")}
|
||||||
|
</Text>
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={series}
|
||||||
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
|
renderItem={(props) =>
|
||||||
|
renderFilmographyItem(props, movies.length === 0)
|
||||||
|
}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={4}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state - only show if both sections are empty and not loading */}
|
||||||
|
{!isLoadingMovies &&
|
||||||
|
!isLoadingSeries &&
|
||||||
|
movies.length === 0 &&
|
||||||
|
series.length === 0 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.no_results")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
106
components/posters/MoviePoster.tv.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_POSTER_WIDTH = 260;
|
||||||
|
|
||||||
|
type MoviePosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||||
|
item,
|
||||||
|
showProgress = false,
|
||||||
|
}) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
return getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
width: 520, // 2x for quality on large screens
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
const isWatched = item.UserData?.Played === true;
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
// Use glass effect on tvOS 26+
|
||||||
|
const useGlass = isGlassEffectAvailable();
|
||||||
|
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={url ?? null}
|
||||||
|
aspectRatio={10 / 15}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={showProgress ? progress : 0}
|
||||||
|
showWatchedIndicator={isWatched}
|
||||||
|
isFocused={false}
|
||||||
|
width={TV_POSTER_WIDTH}
|
||||||
|
style={{ width: TV_POSTER_WIDTH }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older tvOS versions
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<WatchedIndicator item={item} />
|
||||||
|
{showProgress && progress > 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MoviePoster;
|
||||||