Compare commits
73 Commits
remove-opt
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
493df28b8d | ||
|
|
749473c1e8 | ||
|
|
f8d1fad6d5 | ||
|
|
81af2afef8 | ||
|
|
9ef79ef364 | ||
|
|
c7077bbcfe | ||
|
|
c0f25a2b8b | ||
|
|
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 | ||
|
|
36304ad58e | ||
|
|
baeb83581e | ||
|
|
05b7a4c50d | ||
|
|
28b67f3ad6 | ||
|
|
51cd195bfe | ||
|
|
0184e266a0 |
2
.github/workflows/linting.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/update-issue-form.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- name: "🟢 Setup Node.js"
|
- name: "🟢 Setup Node.js"
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.x'
|
node-version: '24.x'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
5
.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
|
||||||
@@ -72,4 +70,5 @@ modules/background-downloader/android/build/*
|
|||||||
/modules/mpv-player/android/build
|
/modules/mpv-player/android/build
|
||||||
|
|
||||||
# ios:unsigned-build Artifacts
|
# ios:unsigned-build Artifacts
|
||||||
build/
|
build/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
205
CLAUDE.md
@@ -134,3 +134,208 @@ 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 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.
|
||||||
|
|||||||
22
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,7 @@
|
|||||||
["./plugins/withAndroidManifest.js"],
|
["./plugins/withAndroidManifest.js"],
|
||||||
["./plugins/withTrustLocalCerts.js"],
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
["./plugins/withGradleProperties.js"],
|
["./plugins/withGradleProperties.js"],
|
||||||
|
["./plugins/withTVOSAppIcon.js"],
|
||||||
[
|
[
|
||||||
"./plugins/withGitPod.js",
|
"./plugins/withGitPod.js",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
|||||||
385
app/(auth)/(tabs)/(home)/settings.tv.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { TVOptionItem } from "@/components/tv";
|
||||||
|
import {
|
||||||
|
TVLogoutButton,
|
||||||
|
TVSectionHeader,
|
||||||
|
TVSettingsOptionButton,
|
||||||
|
TVSettingsRow,
|
||||||
|
TVSettingsStepper,
|
||||||
|
TVSettingsTextInput,
|
||||||
|
TVSettingsToggle,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function SettingsTV() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
const { logout } = useJellyfin();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
|
settings.openSubtitlesApiKey || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAudioTranscode =
|
||||||
|
settings.audioTranscodeMode || AudioTranscodeMode.Auto;
|
||||||
|
const currentSubtitleMode =
|
||||||
|
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||||
|
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||||
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
|
|
||||||
|
// Audio transcoding options
|
||||||
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.auto"),
|
||||||
|
value: AudioTranscodeMode.Auto,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.Auto,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.stereo"),
|
||||||
|
value: AudioTranscodeMode.ForceStereo,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.ForceStereo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.5_1"),
|
||||||
|
value: AudioTranscodeMode.Allow51,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.Allow51,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.audio.transcode_mode.passthrough"),
|
||||||
|
value: AudioTranscodeMode.AllowAll,
|
||||||
|
selected: currentAudioTranscode === AudioTranscodeMode.AllowAll,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentAudioTranscode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subtitle mode options
|
||||||
|
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Default"),
|
||||||
|
value: SubtitlePlaybackMode.Default,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Default,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Smart"),
|
||||||
|
value: SubtitlePlaybackMode.Smart,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Smart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.OnlyForced"),
|
||||||
|
value: SubtitlePlaybackMode.OnlyForced,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.OnlyForced,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.Always"),
|
||||||
|
value: SubtitlePlaybackMode.Always,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.Always,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("home.settings.subtitles.modes.None"),
|
||||||
|
value: SubtitlePlaybackMode.None,
|
||||||
|
selected: currentSubtitleMode === SubtitlePlaybackMode.None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, currentSubtitleMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// MPV alignment options
|
||||||
|
const alignXOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Left", value: "left", selected: currentAlignX === "left" },
|
||||||
|
{
|
||||||
|
label: "Center",
|
||||||
|
value: "center",
|
||||||
|
selected: currentAlignX === "center",
|
||||||
|
},
|
||||||
|
{ label: "Right", value: "right", selected: currentAlignX === "right" },
|
||||||
|
],
|
||||||
|
[currentAlignX],
|
||||||
|
);
|
||||||
|
|
||||||
|
const alignYOptions: TVOptionItem<string>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: "Top", value: "top", selected: currentAlignY === "top" },
|
||||||
|
{
|
||||||
|
label: "Center",
|
||||||
|
value: "center",
|
||||||
|
selected: currentAlignY === "center",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Bottom",
|
||||||
|
value: "bottom",
|
||||||
|
selected: currentAlignY === "bottom",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[currentAlignY],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get display labels for option buttons
|
||||||
|
const audioTranscodeLabel = useMemo(() => {
|
||||||
|
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.audio.transcode_mode.auto");
|
||||||
|
}, [audioTranscodeModeOptions, t]);
|
||||||
|
|
||||||
|
const subtitleModeLabel = useMemo(() => {
|
||||||
|
const option = subtitleModeOptions.find((o) => o.selected);
|
||||||
|
return option?.label || t("home.settings.subtitles.modes.Default");
|
||||||
|
}, [subtitleModeOptions, t]);
|
||||||
|
|
||||||
|
const alignXLabel = useMemo(() => {
|
||||||
|
const option = alignXOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Center";
|
||||||
|
}, [alignXOptions]);
|
||||||
|
|
||||||
|
const alignYLabel = useMemo(() => {
|
||||||
|
const option = alignYOptions.find((o) => o.selected);
|
||||||
|
return option?.label || "Bottom";
|
||||||
|
}, [alignYOptions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 120,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.settings_title")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Audio Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.audio.transcode_mode.title")}
|
||||||
|
value={audioTranscodeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.audio.transcode_mode.title"),
|
||||||
|
options: audioTranscodeModeOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({ audioTranscodeMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isFirst
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.subtitles.subtitle_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label={t("home.settings.subtitles.subtitle_mode")}
|
||||||
|
value={subtitleModeLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("home.settings.subtitles.subtitle_mode"),
|
||||||
|
options: subtitleModeOptions,
|
||||||
|
onSelect: (value) => updateSettings({ subtitleMode: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.subtitles.set_subtitle_track")}
|
||||||
|
value={settings.rememberSubtitleSelections}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ rememberSubtitleSelections: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label={t("home.settings.subtitles.subtitle_size")}
|
||||||
|
value={settings.subtitleSize / 100}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(0.3, settings.subtitleSize / 100 - 0.1);
|
||||||
|
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(1.5, settings.subtitleSize / 100 + 0.1);
|
||||||
|
updateSettings({ subtitleSize: Math.round(newValue * 100) });
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* MPV Subtitles Section */}
|
||||||
|
<TVSectionHeader title='MPV Subtitle Settings' />
|
||||||
|
<TVSettingsStepper
|
||||||
|
label='Subtitle Scale'
|
||||||
|
value={settings.mpvSubtitleScale ?? 1.0}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
0.5,
|
||||||
|
(settings.mpvSubtitleScale ?? 1.0) - 0.1,
|
||||||
|
);
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
2.0,
|
||||||
|
(settings.mpvSubtitleScale ?? 1.0) + 0.1,
|
||||||
|
);
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
||||||
|
/>
|
||||||
|
<TVSettingsStepper
|
||||||
|
label='Vertical Margin'
|
||||||
|
value={settings.mpvSubtitleMarginY ?? 0}
|
||||||
|
onDecrease={() => {
|
||||||
|
const newValue = Math.max(
|
||||||
|
0,
|
||||||
|
(settings.mpvSubtitleMarginY ?? 0) - 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
|
}}
|
||||||
|
onIncrease={() => {
|
||||||
|
const newValue = Math.min(
|
||||||
|
100,
|
||||||
|
(settings.mpvSubtitleMarginY ?? 0) + 5,
|
||||||
|
);
|
||||||
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label='Horizontal Alignment'
|
||||||
|
value={alignXLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: "Horizontal Alignment",
|
||||||
|
options: alignXOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleAlignX: value as "left" | "center" | "right",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsOptionButton
|
||||||
|
label='Vertical Alignment'
|
||||||
|
value={alignYLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: "Vertical Alignment",
|
||||||
|
options: alignYOptions,
|
||||||
|
onSelect: (value) =>
|
||||||
|
updateSettings({
|
||||||
|
mpvSubtitleAlignY: value as "top" | "center" | "bottom",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* OpenSubtitles Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={
|
||||||
|
t("home.settings.subtitles.opensubtitles_title") ||
|
||||||
|
"OpenSubtitles"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontSize: 14,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_hint") ||
|
||||||
|
"Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."}
|
||||||
|
</Text>
|
||||||
|
<TVSettingsTextInput
|
||||||
|
label={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key") || "API Key"
|
||||||
|
}
|
||||||
|
value={openSubtitlesApiKey}
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||||
|
"Enter API key..."
|
||||||
|
}
|
||||||
|
onChangeText={setOpenSubtitlesApiKey}
|
||||||
|
onBlur={() => updateSettings({ openSubtitlesApiKey })}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#6B7280",
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||||
|
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Appearance Section */}
|
||||||
|
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t(
|
||||||
|
"home.settings.appearance.merge_next_up_continue_watching",
|
||||||
|
)}
|
||||||
|
value={settings.mergeNextUpAndContinueWatching}
|
||||||
|
onToggle={(value) =>
|
||||||
|
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
|
value={settings.showHomeBackdrop}
|
||||||
|
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Section */}
|
||||||
|
<TVSectionHeader
|
||||||
|
title={t("home.settings.user_info.user_info_title")}
|
||||||
|
/>
|
||||||
|
<TVSettingsRow
|
||||||
|
label={t("home.settings.user_info.user")}
|
||||||
|
value={user?.Name || "-"}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
<TVSettingsRow
|
||||||
|
label={t("home.settings.user_info.server")}
|
||||||
|
value={api?.basePath || "-"}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<View style={{ marginTop: 48, alignItems: "center" }}>
|
||||||
|
<TVLogoutButton onPress={logout} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -159,6 +160,19 @@ const page: React.FC = () => {
|
|||||||
// For offline mode, we can show the page even without backdropUrl
|
// For offline mode, we can show the page even without backdropUrl
|
||||||
if (!item || (!isOffline && !backdropUrl)) return null;
|
if (!item || (!isOffline && !backdropUrl)) return null;
|
||||||
|
|
||||||
|
// TV version
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
|
<TVSeriesPage
|
||||||
|
item={item}
|
||||||
|
allEpisodes={allEpisodes}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</OfflineModeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfflineModeProvider isOffline={isOffline}>
|
<OfflineModeProvider isOffline={isOffline}>
|
||||||
<ParallaxScrollView
|
<ParallaxScrollView
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -11,19 +12,44 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
@@ -49,6 +75,280 @@ import {
|
|||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter Types and Components
|
||||||
|
type TVFilterModalType =
|
||||||
|
| "genre"
|
||||||
|
| "year"
|
||||||
|
| "tags"
|
||||||
|
| "sortBy"
|
||||||
|
| "sortOrder"
|
||||||
|
| "filterBy"
|
||||||
|
| null;
|
||||||
|
|
||||||
|
interface TVFilterOption<T> {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVFilterOptionCard: React.FC<{
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
width: 160,
|
||||||
|
height: 75,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused || selected ? "600" : "400",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{selected && !focused && (
|
||||||
|
<View style={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={16}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TVFilterButton: React.FC<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasActiveFilter?: boolean;
|
||||||
|
}> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled,
|
||||||
|
hasActiveFilter,
|
||||||
|
}) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 120,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.04);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ scale }] }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: hasActiveFilter
|
||||||
|
? "rgba(255, 255, 255, 0.25)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label ? (
|
||||||
|
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TVFilterSelector = <T,>({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
options: TVFilterOption<T>[];
|
||||||
|
onSelect: (value: T) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
// Track initial focus index - only set once when modal opens
|
||||||
|
const initialFocusIndexRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Calculate initial focus index only once when visible becomes true
|
||||||
|
if (visible && initialFocusIndexRef.current === null) {
|
||||||
|
const idx = options.findIndex((o) => o.selected);
|
||||||
|
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset when modal closes
|
||||||
|
if (!visible) {
|
||||||
|
initialFocusIndexRef.current = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ paddingVertical: 24 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<TVFilterOptionCard
|
||||||
|
key={String(option.value)}
|
||||||
|
label={option.label}
|
||||||
|
selected={option.selected}
|
||||||
|
hasTVPreferredFocus={index === initialFocusIndex}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(option.value);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
libraryId: string;
|
libraryId: string;
|
||||||
@@ -79,6 +379,54 @@ const Page = () => {
|
|||||||
const { orientation } = useOrientation();
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// TV Filter modal state
|
||||||
|
const [openFilterModal, setOpenFilterModal] =
|
||||||
|
useState<TVFilterModalType>(null);
|
||||||
|
const isFilterModalOpen = openFilterModal !== null;
|
||||||
|
|
||||||
|
const isFiltersDisabled = isFilterModalOpen;
|
||||||
|
|
||||||
|
// TV Filter queries
|
||||||
|
const { data: tvGenreOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Genres || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvYearOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Years", "tvYearFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Years || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: tvTagOptions } = useQuery({
|
||||||
|
queryKey: ["filters", "Tags", "tvTagFilter", libraryId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api) return [];
|
||||||
|
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||||
|
userId: user?.Id,
|
||||||
|
parentId: libraryId,
|
||||||
|
});
|
||||||
|
return response.data.Tags || [];
|
||||||
|
},
|
||||||
|
enabled: Platform.isTV && !!api && !!user?.Id && !!libraryId,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for URL params first (from "See All" navigation)
|
// Check for URL params first (from "See All" navigation)
|
||||||
@@ -162,6 +510,14 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// Calculate columns based on TV poster width + gap
|
||||||
|
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -322,7 +678,38 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation],
|
[orientation, nrOfCols],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(libraries)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type !== "Movie" &&
|
||||||
|
item.Type !== "Series" &&
|
||||||
|
item.Type !== "Episode" && <MoviePoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router, isFilterModalOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -509,6 +896,156 @@ const Page = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter bar header
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0 ||
|
||||||
|
filterBy.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
_setFilterBy([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVFilterOption<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVFilterOption<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVFilterOption<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVFilterOption<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVFilterOption<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvFilterByOptions = useMemo(
|
||||||
|
(): TVFilterOption<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: filterBy.length === 0,
|
||||||
|
},
|
||||||
|
...generalFilters.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: filterBy.includes(option.key),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[filterBy, generalFilters, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers
|
||||||
|
const handleGenreSelect = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedGenres, setSelectedGenres],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleYearSelect = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedYears, setSelectedYears],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTagSelect = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((t) => t !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedTags, setSelectedTags],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFilterBySelect = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
_setFilterBy([]);
|
||||||
|
} else {
|
||||||
|
setFilter([value as FilterByOption]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setFilter, _setFilterBy],
|
||||||
|
);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
@@ -518,43 +1055,227 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
// Mobile return
|
||||||
<FlashList
|
if (!Platform.isTV) {
|
||||||
key={orientation}
|
return (
|
||||||
ListEmptyComponent={
|
<FlashList
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
key={orientation}
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
ListEmptyComponent={
|
||||||
{t("library.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("library.no_results")}
|
||||||
}
|
</Text>
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
</View>
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
extraData={[orientation, nrOfCols]}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={nrOfCols}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
}
|
||||||
}}
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
onEndReachedThreshold={1}
|
data={flatData}
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
renderItem={renderItem}
|
||||||
contentContainerStyle={{
|
extraData={[orientation, nrOfCols]}
|
||||||
paddingBottom: 24,
|
keyExtractor={keyExtractor}
|
||||||
paddingLeft: insets.left,
|
numColumns={nrOfCols}
|
||||||
paddingRight: insets.right,
|
onEndReached={() => {
|
||||||
}}
|
if (hasNextPage) {
|
||||||
ItemSeparatorComponent={() => (
|
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 overlays - filter bar outside FlatList to fix focus boundary issues
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Background content - disabled when modal is open */}
|
||||||
|
<View
|
||||||
|
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
|
||||||
|
focusable={!isFilterModalOpen}
|
||||||
|
isTVSelectable={!isFilterModalOpen}
|
||||||
|
pointerEvents={isFilterModalOpen ? "none" : "auto"}
|
||||||
|
accessibilityElementsHidden={isFilterModalOpen}
|
||||||
|
importantForAccessibility={
|
||||||
|
isFilterModalOpen ? "no-hide-descendants" : "auto"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: 10,
|
flexDirection: "row",
|
||||||
height: 10,
|
flexWrap: "nowrap",
|
||||||
|
marginTop: insets.top + 100,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={() => setOpenFilterModal("genre")}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={() => setOpenFilterModal("year")}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={() => setOpenFilterModal("tags")}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={() => setOpenFilterModal("sortBy")}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={() => setOpenFilterModal("sortOrder")}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.filter_by")}
|
||||||
|
value={
|
||||||
|
filterBy.length > 0
|
||||||
|
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={() => setOpenFilterModal("filterBy")}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
hasActiveFilter={filterBy.length > 0}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols, isFilterModalOpen]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: TV_SCALE_PADDING,
|
||||||
|
paddingRight: TV_SCALE_PADDING,
|
||||||
|
paddingTop: 20,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
</View>
|
||||||
/>
|
|
||||||
|
{/* TV Filter Overlays */}
|
||||||
|
<TVFilterSelector
|
||||||
|
visible={openFilterModal === "genre"}
|
||||||
|
title={t("library.filters.genres")}
|
||||||
|
options={tvGenreFilterOptions}
|
||||||
|
onSelect={handleGenreSelect}
|
||||||
|
onClose={() => setOpenFilterModal(null)}
|
||||||
|
/>
|
||||||
|
<TVFilterSelector
|
||||||
|
visible={openFilterModal === "year"}
|
||||||
|
title={t("library.filters.years")}
|
||||||
|
options={tvYearFilterOptions}
|
||||||
|
onSelect={handleYearSelect}
|
||||||
|
onClose={() => setOpenFilterModal(null)}
|
||||||
|
/>
|
||||||
|
<TVFilterSelector
|
||||||
|
visible={openFilterModal === "tags"}
|
||||||
|
title={t("library.filters.tags")}
|
||||||
|
options={tvTagFilterOptions}
|
||||||
|
onSelect={handleTagSelect}
|
||||||
|
onClose={() => setOpenFilterModal(null)}
|
||||||
|
/>
|
||||||
|
<TVFilterSelector
|
||||||
|
visible={openFilterModal === "sortBy"}
|
||||||
|
title={t("library.filters.sort_by")}
|
||||||
|
options={tvSortByOptions}
|
||||||
|
onSelect={(value) => setSortBy([value])}
|
||||||
|
onClose={() => setOpenFilterModal(null)}
|
||||||
|
/>
|
||||||
|
<TVFilterSelector
|
||||||
|
visible={openFilterModal === "sortOrder"}
|
||||||
|
title={t("library.filters.sort_order")}
|
||||||
|
options={tvSortOrderOptions}
|
||||||
|
onSelect={(value) => setSortOrder([value])}
|
||||||
|
onClose={() => setOpenFilterModal(null)}
|
||||||
|
/>
|
||||||
|
<TVFilterSelector
|
||||||
|
visible={openFilterModal === "filterBy"}
|
||||||
|
title={t("library.filters.filter_by")}
|
||||||
|
options={tvFilterByOptions}
|
||||||
|
onSelect={handleFilterBySelect}
|
||||||
|
onClose={() => setOpenFilterModal(null)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +7,7 @@ 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 {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -22,9 +22,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,6 +38,7 @@ 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";
|
||||||
@@ -59,6 +62,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 +204,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 +443,38 @@ 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
keyboardDismissMode='on-drag'
|
keyboardDismissMode='on-drag'
|
||||||
@@ -450,26 +485,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,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
@@ -16,9 +17,17 @@ import {
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
getItemNavigation,
|
||||||
|
TouchableItemRouter,
|
||||||
|
} from "@/components/common/TouchableItemRouter";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +41,20 @@ import {
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const TV_ITEM_GAP = 16;
|
||||||
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -47,6 +70,14 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
// Calculate columns based on TV poster width + gap
|
||||||
|
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -153,6 +184,37 @@ export default function WatchlistDetailScreen() {
|
|||||||
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
[removeFromWatchlist, watchlistIdNum, watchlist?.name, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTVItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={handlePress}
|
||||||
|
hasTVPreferredFocus={index === 0}
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
<TouchableItemRouter
|
<TouchableItemRouter
|
||||||
@@ -278,13 +340,14 @@ export default function WatchlistDetailScreen() {
|
|||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
|
||||||
|
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
}
|
}
|
||||||
renderItem={renderItem}
|
renderItem={Platform.isTV ? renderTVItem : renderItem}
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -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,12 +11,18 @@ import { withLayoutContext } from "expo-router";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { MiniPlayerBar } from "@/components/music/MiniPlayerBar";
|
|
||||||
import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { eventBus } from "@/utils/eventBus";
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
|
// Music components are not available on tvOS (TrackPlayer not supported)
|
||||||
|
const MiniPlayerBar = Platform.isTV
|
||||||
|
? () => null
|
||||||
|
: require("@/components/music/MiniPlayerBar").MiniPlayerBar;
|
||||||
|
const MusicPlaybackEngine = Platform.isTV
|
||||||
|
? () => null
|
||||||
|
: require("@/components/music/MusicPlaybackEngine").MusicPlaybackEngine;
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
@@ -117,6 +123,17 @@ export default function TabLayout() {
|
|||||||
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
: (_e) => ({ sfSymbol: "list.dash.fill" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name='(settings)'
|
||||||
|
options={{
|
||||||
|
title: t("tabs.settings"),
|
||||||
|
tabBarItemHidden: !Platform.isTV,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
|
: (_e) => ({ sfSymbol: "gearshape.fill" }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
<MiniPlayerBar />
|
<MiniPlayerBar />
|
||||||
<MusicPlaybackEngine />
|
<MusicPlaybackEngine />
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -449,7 +480,7 @@ export default function page() {
|
|||||||
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
async (data: { nativeEvent: MpvOnProgressEventPayload }) => {
|
||||||
if (isSeeking.get() || isPlaybackStopped) return;
|
if (isSeeking.get() || isPlaybackStopped) return;
|
||||||
|
|
||||||
const { position } = data.nativeEvent;
|
const { position, cacheSeconds } = data.nativeEvent;
|
||||||
// MPV reports position in seconds, convert to ms
|
// MPV reports position in seconds, convert to ms
|
||||||
const currentTime = position * 1000;
|
const currentTime = position * 1000;
|
||||||
|
|
||||||
@@ -459,6 +490,12 @@ export default function page() {
|
|||||||
|
|
||||||
progress.set(currentTime);
|
progress.set(currentTime);
|
||||||
|
|
||||||
|
// Update cache progress (current position + buffered seconds ahead)
|
||||||
|
if (cacheSeconds !== undefined && cacheSeconds > 0) {
|
||||||
|
const cacheEnd = currentTime + cacheSeconds * 1000;
|
||||||
|
cacheProgress.set(cacheEnd);
|
||||||
|
}
|
||||||
|
|
||||||
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
// Update URL immediately after seeking, or every 30 seconds during normal playback
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const shouldUpdateUrl = wasJustSeeking.get();
|
const shouldUpdateUrl = wasJustSeeking.get();
|
||||||
@@ -531,7 +568,11 @@ export default function page() {
|
|||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
isTranscoding,
|
isTranscoding,
|
||||||
);
|
);
|
||||||
const initialAudioId = getMpvAudioId(mediaSource, audioIndex);
|
const initialAudioId = getMpvAudioId(
|
||||||
|
mediaSource,
|
||||||
|
audioIndex,
|
||||||
|
isTranscoding,
|
||||||
|
);
|
||||||
|
|
||||||
// Calculate start position directly here to avoid timing issues
|
// Calculate start position directly here to avoid timing issues
|
||||||
const startTicks = playbackPositionFromUrl
|
const startTicks = playbackPositionFromUrl
|
||||||
@@ -727,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);
|
||||||
@@ -816,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;
|
||||||
@@ -954,37 +1147,66 @@ 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}
|
/>
|
||||||
/>
|
) : (
|
||||||
)}
|
<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: 10,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
1272
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,22 @@ 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>
|
</Stack>
|
||||||
<Toaster
|
<Toaster
|
||||||
duration={4000}
|
duration={4000}
|
||||||
@@ -443,7 +460,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 |
12
bun.lock
@@ -58,7 +58,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-i18next": "16.5.3",
|
"react-i18next": "16.5.3",
|
||||||
"react-native": "0.81.5",
|
"react-native": "npm:react-native-tvos@0.81.5-2",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
"react-native-bottom-tabs": "1.1.0",
|
"react-native-bottom-tabs": "1.1.0",
|
||||||
"react-native-circular-progress": "^1.4.1",
|
"react-native-circular-progress": "^1.4.1",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
"react-native-ios-context-menu": "^3.2.1",
|
"react-native-ios-context-menu": "^3.2.1",
|
||||||
"react-native-ios-utilities": "5.2.0",
|
"react-native-ios-utilities": "5.2.0",
|
||||||
"react-native-mmkv": "4.1.1",
|
"react-native-mmkv": "4.1.1",
|
||||||
"react-native-nitro-modules": "0.32.1",
|
"react-native-nitro-modules": "0.33.1",
|
||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-reanimated-carousel": "4.0.3",
|
"react-native-reanimated-carousel": "4.0.3",
|
||||||
@@ -540,6 +540,8 @@
|
|||||||
|
|
||||||
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
|
"@react-native-tvos/config-tv": ["@react-native-tvos/config-tv@0.1.4", "", { "dependencies": { "getenv": "^1.0.0" }, "peerDependencies": { "expo": ">=52.0.0" } }, "sha512-xfVDqSFjEUsb+xcMk0hE2Z/M6QZH0QzAJOSQZwo7W/ZRaLrd+xFQnx0LaXqt3kxlR3P7wskKHByDP/FSoUZnbA=="],
|
||||||
|
|
||||||
|
"@react-native-tvos/virtualized-lists": ["@react-native-tvos/virtualized-lists@0.81.5-2", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-i5L6sJ8Dae5JUWhfb5w/RgZUm3CYRFhV5/PB/xu3ASxFyHjfO0kQAqcU3ySNAOR0HfmaXK8R4OC0h07zoUWKrQ=="],
|
||||||
|
|
||||||
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
|
"@react-native/assets-registry": ["@react-native/assets-registry@0.81.5", "", {}, "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w=="],
|
||||||
|
|
||||||
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
|
"@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.81.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.81.5" } }, "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ=="],
|
||||||
@@ -560,8 +562,6 @@
|
|||||||
|
|
||||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
||||||
|
|
||||||
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.81.5", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw=="],
|
|
||||||
|
|
||||||
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
||||||
|
|
||||||
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
||||||
@@ -1644,7 +1644,7 @@
|
|||||||
|
|
||||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||||
|
|
||||||
"react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="],
|
"react-native": ["react-native-tvos@0.81.5-2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native-tvos/virtualized-lists": "0.81.5-2", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-y/V8iFZGNXQq6b+X9VBQG19PaBpAXQHhv2vhcCMe2gEePqI2Uu8n3ClqglBn8u+Fl/GXCMcFdnJ0v0nRyxJ5TA=="],
|
||||||
|
|
||||||
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
"react-native-awesome-slider": ["react-native-awesome-slider@2.9.0", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ=="],
|
||||||
|
|
||||||
@@ -1678,7 +1678,7 @@
|
|||||||
|
|
||||||
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
|
"react-native-mmkv": ["react-native-mmkv@4.1.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-nitro-modules": "*" } }, "sha512-nYFjM27l7zVhIiyAqWEFRagGASecb13JMIlzAuOeakRRz9GMJ49hCQntUBE2aeuZRE4u4ehSqTOomB0mTF56Ew=="],
|
||||||
|
|
||||||
"react-native-nitro-modules": ["react-native-nitro-modules@0.32.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-V+Vy76e4fxRxgVGu5Uh3cBPvuFQW8fM1OUKk1mqEA/JawjhX+hxHtBhpfuvNjV0BnV/uXCIg8/eK+rTpB6tqFg=="],
|
"react-native-nitro-modules": ["react-native-nitro-modules@0.33.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-Kdo8qiqlkGAEs7fq29i0yiZs0Gf7ucmMiFsH8PH4uzsnSGEt2CQRBJGnQKKMl9vJYL8e7rzA0TZKRwO/L8G/Sg=="],
|
||||||
|
|
||||||
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
|
"react-native-pager-view": ["react-native-pager-view@6.9.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw=="],
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,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 +38,37 @@ export const Badge: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On TV, use transparent backgrounds for a cleaner look
|
||||||
|
const isTV = Platform.isTV;
|
||||||
|
|
||||||
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: isTV
|
||||||
|
? "rgba(255,255,255,0.1)"
|
||||||
|
: 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
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
128
components/ContinueWatchingPoster.tv.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { ProgressBar } from "./common/ProgressBar";
|
||||||
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
|
export const TV_LANDSCAPE_WIDTH = 340;
|
||||||
|
|
||||||
|
type ContinueWatchingPosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
useEpisodePoster?: boolean;
|
||||||
|
size?: "small" | "normal";
|
||||||
|
showPlayButton?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||||
|
item,
|
||||||
|
useEpisodePoster = false,
|
||||||
|
// TV version uses fixed width, size prop kept for API compatibility
|
||||||
|
size: _size = "normal",
|
||||||
|
showPlayButton = false,
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode" && useEpisodePoster) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie") {
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}, [api, item, useEpisodePoster]);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_LANDSCAPE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: TV_LANDSCAPE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={{
|
||||||
|
uri: url,
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-circle' size={56} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{!item.UserData?.Played && <WatchedIndicator item={item} />}
|
||||||
|
<ProgressBar item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContinueWatchingPoster;
|
||||||
@@ -23,7 +23,7 @@ export const Tag: React.FC<
|
|||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
} & ViewProps
|
} & ViewProps
|
||||||
> = ({ text, textClass, textStyle, ...props }) => {
|
> = ({ text, textClass, textStyle, ...props }) => {
|
||||||
if (Platform.OS === "ios") {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<GlassEffectView style={styles.glass}>
|
<GlassEffectView style={styles.glass}>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
);
|
};
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|||||||
725
components/ItemContent.tv.tsx
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
MediaSourceInfo,
|
||||||
|
MediaStream,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import {
|
||||||
|
TVBackdrop,
|
||||||
|
TVButton,
|
||||||
|
TVCastCrewText,
|
||||||
|
TVCastSection,
|
||||||
|
TVMetadataBadges,
|
||||||
|
TVOptionButton,
|
||||||
|
TVProgressBar,
|
||||||
|
TVRefreshButton,
|
||||||
|
TVSeriesNavigation,
|
||||||
|
TVTechnicalDetails,
|
||||||
|
} from "@/components/tv";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
|
import { useTVSubtitleModal } from "@/hooks/useTVSubtitleModal";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
export type SelectedOptions = {
|
||||||
|
bitrate: Bitrate;
|
||||||
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
|
audioIndex: number | undefined;
|
||||||
|
subtitleIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ItemContentTVProps {
|
||||||
|
item?: BaseItemDto | null;
|
||||||
|
itemWithSources?: BaseItemDto | null;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
||||||
|
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||||
|
({ item, itemWithSources }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [_user] = useAtom(userAtom);
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const _itemColors = useImageColorsReturn({ item });
|
||||||
|
|
||||||
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
|
SelectedOptions | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultMediaSource,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(
|
||||||
|
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||||
|
[api, item],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set default play options
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedOptions(() => ({
|
||||||
|
bitrate: defaultBitrate,
|
||||||
|
mediaSource: defaultMediaSource ?? undefined,
|
||||||
|
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||||
|
audioIndex: defaultAudioIndex,
|
||||||
|
}));
|
||||||
|
}, [
|
||||||
|
defaultAudioIndex,
|
||||||
|
defaultBitrate,
|
||||||
|
defaultSubtitleIndex,
|
||||||
|
defaultMediaSource,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
if (!item || !selectedOptions) return;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
itemId: item.Id!,
|
||||||
|
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||||
|
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
|
||||||
|
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
|
||||||
|
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
|
||||||
|
playbackPosition:
|
||||||
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||||
|
offline: isOffline ? "true" : "false",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV Option Modal hook for quality, audio, media source selectors
|
||||||
|
const { showOptions } = useTVOptionModal();
|
||||||
|
|
||||||
|
// TV Subtitle Modal hook
|
||||||
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
|
// State for first actor card ref (used for focus guide)
|
||||||
|
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// State for last option button ref (used for upward focus guide from cast)
|
||||||
|
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get available audio tracks
|
||||||
|
const audioTracks = useMemo(() => {
|
||||||
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Audio",
|
||||||
|
);
|
||||||
|
return streams ?? [];
|
||||||
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
|
// Get available subtitle tracks
|
||||||
|
const subtitleTracks = useMemo(() => {
|
||||||
|
const streams = selectedOptions?.mediaSource?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Subtitle",
|
||||||
|
);
|
||||||
|
return streams ?? [];
|
||||||
|
}, [selectedOptions?.mediaSource]);
|
||||||
|
|
||||||
|
// Get available media sources
|
||||||
|
const mediaSources = useMemo(() => {
|
||||||
|
return (itemWithSources ?? item)?.MediaSources ?? [];
|
||||||
|
}, [item, itemWithSources]);
|
||||||
|
|
||||||
|
// Audio options for selector
|
||||||
|
const audioOptions: TVOptionItem<number>[] = useMemo(() => {
|
||||||
|
return audioTracks.map((track) => ({
|
||||||
|
label:
|
||||||
|
track.DisplayTitle ||
|
||||||
|
`${track.Language || "Unknown"} (${track.Codec})`,
|
||||||
|
value: track.Index!,
|
||||||
|
selected: track.Index === selectedOptions?.audioIndex,
|
||||||
|
}));
|
||||||
|
}, [audioTracks, selectedOptions?.audioIndex]);
|
||||||
|
|
||||||
|
// Media source options for selector
|
||||||
|
const mediaSourceOptions: TVOptionItem<MediaSourceInfo>[] = useMemo(() => {
|
||||||
|
return mediaSources.map((source) => {
|
||||||
|
const videoStream = source.MediaStreams?.find(
|
||||||
|
(s) => s.Type === "Video",
|
||||||
|
);
|
||||||
|
const displayName =
|
||||||
|
videoStream?.DisplayTitle || source.Name || `Source ${source.Id}`;
|
||||||
|
return {
|
||||||
|
label: displayName,
|
||||||
|
value: source,
|
||||||
|
selected: source.Id === selectedOptions?.mediaSource?.Id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
|
||||||
|
|
||||||
|
// Quality/bitrate options for selector
|
||||||
|
const qualityOptions: TVOptionItem<Bitrate>[] = useMemo(() => {
|
||||||
|
return BITRATES.map((bitrate) => ({
|
||||||
|
label: bitrate.key,
|
||||||
|
value: bitrate,
|
||||||
|
selected: bitrate.value === selectedOptions?.bitrate?.value,
|
||||||
|
}));
|
||||||
|
}, [selectedOptions?.bitrate?.value]);
|
||||||
|
|
||||||
|
// Handlers for option changes
|
||||||
|
const handleAudioChange = useCallback((audioIndex: number) => {
|
||||||
|
setSelectedOptions((prev) =>
|
||||||
|
prev ? { ...prev, audioIndex } : undefined,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubtitleChange = useCallback((subtitleIndex: number) => {
|
||||||
|
setSelectedOptions((prev) =>
|
||||||
|
prev ? { ...prev, subtitleIndex } : undefined,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMediaSourceChange = useCallback(
|
||||||
|
(mediaSource: MediaSourceInfo) => {
|
||||||
|
const defaultAudio = mediaSource.MediaStreams?.find(
|
||||||
|
(s) => s.Type === "Audio" && s.IsDefault,
|
||||||
|
);
|
||||||
|
const defaultSubtitle = mediaSource.MediaStreams?.find(
|
||||||
|
(s) => s.Type === "Subtitle" && s.IsDefault,
|
||||||
|
);
|
||||||
|
setSelectedOptions((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
mediaSource,
|
||||||
|
audioIndex: defaultAudio?.Index ?? prev.audioIndex,
|
||||||
|
subtitleIndex: defaultSubtitle?.Index ?? -1,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleQualityChange = useCallback((bitrate: Bitrate) => {
|
||||||
|
setSelectedOptions((prev) => (prev ? { ...prev, bitrate } : undefined));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle server-side subtitle download - invalidate queries to refresh tracks
|
||||||
|
const handleServerSubtitleDownloaded = useCallback(() => {
|
||||||
|
if (item?.Id) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["item", item.Id] });
|
||||||
|
}
|
||||||
|
}, [item?.Id, queryClient]);
|
||||||
|
|
||||||
|
// Refresh subtitle tracks by fetching fresh item data from Jellyfin
|
||||||
|
const refreshSubtitleTracks = useCallback(async (): Promise<
|
||||||
|
MediaStream[]
|
||||||
|
> => {
|
||||||
|
if (!api || !item?.Id) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch fresh item data with media sources
|
||||||
|
const response = await getUserLibraryApi(api).getItem({
|
||||||
|
itemId: item.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshItem = response.data;
|
||||||
|
const mediaSourceId = selectedOptions?.mediaSource?.Id;
|
||||||
|
|
||||||
|
// Find the matching media source
|
||||||
|
const mediaSource = mediaSourceId
|
||||||
|
? freshItem.MediaSources?.find(
|
||||||
|
(s: MediaSourceInfo) => s.Id === mediaSourceId,
|
||||||
|
)
|
||||||
|
: freshItem.MediaSources?.[0];
|
||||||
|
|
||||||
|
// Return subtitle tracks from the fresh data
|
||||||
|
return (
|
||||||
|
mediaSource?.MediaStreams?.filter(
|
||||||
|
(s: MediaStream) => s.Type === "Subtitle",
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh subtitle tracks:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [api, item?.Id, selectedOptions?.mediaSource?.Id]);
|
||||||
|
|
||||||
|
// Get display values for buttons
|
||||||
|
const selectedAudioLabel = useMemo(() => {
|
||||||
|
const track = audioTracks.find(
|
||||||
|
(t) => t.Index === selectedOptions?.audioIndex,
|
||||||
|
);
|
||||||
|
return track?.DisplayTitle || track?.Language || t("item_card.audio");
|
||||||
|
}, [audioTracks, selectedOptions?.audioIndex, t]);
|
||||||
|
|
||||||
|
const selectedSubtitleLabel = useMemo(() => {
|
||||||
|
if (selectedOptions?.subtitleIndex === -1)
|
||||||
|
return t("item_card.subtitles.none");
|
||||||
|
const track = subtitleTracks.find(
|
||||||
|
(t) => t.Index === selectedOptions?.subtitleIndex,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
track?.DisplayTitle || track?.Language || t("item_card.subtitles.label")
|
||||||
|
);
|
||||||
|
}, [subtitleTracks, selectedOptions?.subtitleIndex, t]);
|
||||||
|
|
||||||
|
const selectedMediaSourceLabel = useMemo(() => {
|
||||||
|
const source = selectedOptions?.mediaSource;
|
||||||
|
if (!source) return t("item_card.video");
|
||||||
|
const videoStream = source.MediaStreams?.find((s) => s.Type === "Video");
|
||||||
|
return videoStream?.DisplayTitle || source.Name || t("item_card.video");
|
||||||
|
}, [selectedOptions?.mediaSource, t]);
|
||||||
|
|
||||||
|
const selectedQualityLabel = useMemo(() => {
|
||||||
|
return selectedOptions?.bitrate?.key || t("item_card.quality");
|
||||||
|
}, [selectedOptions?.bitrate?.key, t]);
|
||||||
|
|
||||||
|
// Format year and duration
|
||||||
|
const year = item?.ProductionYear;
|
||||||
|
const duration = item?.RunTimeTicks
|
||||||
|
? runtimeTicksToMinutes(item.RunTimeTicks)
|
||||||
|
: null;
|
||||||
|
const hasProgress = (item?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
const remainingTime = hasProgress
|
||||||
|
? runtimeTicksToMinutes(
|
||||||
|
(item?.RunTimeTicks || 0) -
|
||||||
|
(item?.UserData?.PlaybackPositionTicks || 0),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Get director
|
||||||
|
const director = item?.People?.find((p) => p.Type === "Director");
|
||||||
|
|
||||||
|
// Get cast (first 3 for text display)
|
||||||
|
const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3);
|
||||||
|
|
||||||
|
// Get full cast for visual display (up to 10 actors)
|
||||||
|
const fullCast = useMemo(() => {
|
||||||
|
return (
|
||||||
|
item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? []
|
||||||
|
);
|
||||||
|
}, [item?.People]);
|
||||||
|
|
||||||
|
// Whether to show visual cast section
|
||||||
|
const showVisualCast =
|
||||||
|
(item?.Type === "Movie" ||
|
||||||
|
item?.Type === "Series" ||
|
||||||
|
item?.Type === "Episode") &&
|
||||||
|
fullCast.length > 0;
|
||||||
|
|
||||||
|
// Series/Season image URLs for episodes
|
||||||
|
const seriesImageUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode" || !item.SeriesId) return null;
|
||||||
|
return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 });
|
||||||
|
}, [api, item?.Type, item?.SeriesId]);
|
||||||
|
|
||||||
|
const seasonImageUrl = useMemo(() => {
|
||||||
|
if (item?.Type !== "Episode") return null;
|
||||||
|
const seasonId = item.SeasonId || item.ParentId;
|
||||||
|
if (!seasonId) return null;
|
||||||
|
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
||||||
|
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
||||||
|
|
||||||
|
// Determine which option button is the last one (for focus guide targeting)
|
||||||
|
const lastOptionButton = useMemo(() => {
|
||||||
|
const hasSubtitleOption =
|
||||||
|
subtitleTracks.length > 0 ||
|
||||||
|
selectedOptions?.subtitleIndex !== undefined;
|
||||||
|
const hasAudioOption = audioTracks.length > 0;
|
||||||
|
const hasMediaSourceOption = mediaSources.length > 1;
|
||||||
|
|
||||||
|
if (hasSubtitleOption) return "subtitle";
|
||||||
|
if (hasAudioOption) return "audio";
|
||||||
|
if (hasMediaSourceOption) return "mediaSource";
|
||||||
|
return "quality";
|
||||||
|
}, [
|
||||||
|
subtitleTracks.length,
|
||||||
|
selectedOptions?.subtitleIndex,
|
||||||
|
audioTracks.length,
|
||||||
|
mediaSources.length,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Navigation handlers
|
||||||
|
const handleActorPress = useCallback(
|
||||||
|
(personId: string) => {
|
||||||
|
router.push(`/(auth)/persons/${personId}`);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSeriesPress = useCallback(() => {
|
||||||
|
if (item?.SeriesId) {
|
||||||
|
router.push(`/(auth)/series/${item.SeriesId}`);
|
||||||
|
}
|
||||||
|
}, [router, item?.SeriesId]);
|
||||||
|
|
||||||
|
const handleSeasonPress = useCallback(() => {
|
||||||
|
if (item?.SeriesId && item?.ParentIndexNumber) {
|
||||||
|
router.push(
|
||||||
|
`/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
|
||||||
|
|
||||||
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Full-screen backdrop */}
|
||||||
|
<TVBackdrop item={item} />
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 140,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + 80,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top section - Logo/Title + Metadata */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Poster */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * 0.22,
|
||||||
|
marginRight: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
variant='Primary'
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: "80%",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 52,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode info for TV shows */}
|
||||||
|
{item.Type === "Episode" && (
|
||||||
|
<View style={{ marginBottom: 12 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontWeight: "600",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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 && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#D1D5DB",
|
||||||
|
lineHeight: 28,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasProgress
|
||||||
|
? `${remainingTime} ${t("item_card.left")}`
|
||||||
|
: t("common.play")}
|
||||||
|
</Text>
|
||||||
|
</TVButton>
|
||||||
|
<TVRefreshButton itemId={item.Id} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Playback options */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Quality selector */}
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "quality"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.quality")}
|
||||||
|
value={selectedQualityLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.quality"),
|
||||||
|
options: qualityOptions,
|
||||||
|
onSelect: handleQualityChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Media source selector (only if multiple sources) */}
|
||||||
|
{mediaSources.length > 1 && (
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "mediaSource"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.video")}
|
||||||
|
value={selectedMediaSourceLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.video"),
|
||||||
|
options: mediaSourceOptions,
|
||||||
|
onSelect: handleMediaSourceChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio selector */}
|
||||||
|
{audioTracks.length > 0 && (
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "audio"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.audio")}
|
||||||
|
value={selectedAudioLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showOptions({
|
||||||
|
title: t("item_card.audio"),
|
||||||
|
options: audioOptions,
|
||||||
|
onSelect: handleAudioChange,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtitle selector */}
|
||||||
|
{(subtitleTracks.length > 0 ||
|
||||||
|
selectedOptions?.subtitleIndex !== undefined) && (
|
||||||
|
<TVOptionButton
|
||||||
|
ref={
|
||||||
|
lastOptionButton === "subtitle"
|
||||||
|
? setLastOptionButtonRef
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={t("item_card.subtitles.label")}
|
||||||
|
value={selectedSubtitleLabel}
|
||||||
|
onPress={() =>
|
||||||
|
showSubtitleModal({
|
||||||
|
item,
|
||||||
|
mediaSourceId: selectedOptions?.mediaSource?.Id,
|
||||||
|
subtitleTracks,
|
||||||
|
currentSubtitleIndex:
|
||||||
|
selectedOptions?.subtitleIndex ?? -1,
|
||||||
|
onSubtitleIndexChange: handleSubtitleChange,
|
||||||
|
onServerSubtitleDownloaded:
|
||||||
|
handleServerSubtitleDownloaded,
|
||||||
|
refreshSubtitleTracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Focus guide to direct navigation from options to cast list */}
|
||||||
|
{fullCast.length > 0 && firstActorCardRef && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[firstActorCardRef]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar (if partially watched) */}
|
||||||
|
{hasProgress && item.RunTimeTicks != null && (
|
||||||
|
<TVProgressBar
|
||||||
|
progress={
|
||||||
|
(item.UserData?.PlaybackPositionTicks || 0) /
|
||||||
|
item.RunTimeTicks
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Additional info section */}
|
||||||
|
<View style={{ marginTop: 40 }}>
|
||||||
|
{/* Cast & Crew (text version) */}
|
||||||
|
<TVCastCrewText
|
||||||
|
director={director}
|
||||||
|
cast={cast}
|
||||||
|
hideCast={showVisualCast}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Technical details */}
|
||||||
|
{selectedOptions.mediaSource?.MediaStreams &&
|
||||||
|
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||||
|
<TVTechnicalDetails
|
||||||
|
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||||
|
{showVisualCast && (
|
||||||
|
<TVCastSection
|
||||||
|
cast={fullCast}
|
||||||
|
apiBasePath={api?.basePath}
|
||||||
|
onActorPress={handleActorPress}
|
||||||
|
firstActorRefSetter={setFirstActorCardRef}
|
||||||
|
upwardFocusDestination={lastOptionButtonRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* From this Series - Episode only */}
|
||||||
|
<TVSeriesNavigation
|
||||||
|
item={item}
|
||||||
|
seriesImageUrl={seriesImageUrl}
|
||||||
|
seasonImageUrl={seasonImageUrl}
|
||||||
|
onSeriesPress={handleSeriesPress}
|
||||||
|
onSeasonPress={handleSeasonPress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alias for platform-resolved imports (tvOS auto-resolves .tv.tsx files)
|
||||||
|
export const ItemContent = ItemContentTV;
|
||||||
160
components/ItemContentSkeleton.tv.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Dimensions, View } from "react-native";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
export const ItemContentSkeletonTV: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingTop: 180,
|
||||||
|
paddingHorizontal: 160,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Poster placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * 0.22,
|
||||||
|
marginRight: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content placeholders */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Logo/Title placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 80,
|
||||||
|
width: "60%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Metadata badges row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
width: 60,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
width: 80,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
width: 50,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Genres placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 80,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 100,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
width: 70,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Overview placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
marginBottom: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 18,
|
||||||
|
width: "100%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 18,
|
||||||
|
width: "90%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 18,
|
||||||
|
width: "75%",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Play button placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
width: 180,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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"}
|
||||||
|
|||||||
231
components/home/Favorites.tv.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
const SECTION_GAP = 10;
|
||||||
|
|
||||||
|
type FavoriteTypes =
|
||||||
|
| "Series"
|
||||||
|
| "Movie"
|
||||||
|
| "Episode"
|
||||||
|
| "Video"
|
||||||
|
| "BoxSet"
|
||||||
|
| "Playlist";
|
||||||
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
|
export const Favorites = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const pageSize = 20;
|
||||||
|
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||||
|
Series: false,
|
||||||
|
Movie: false,
|
||||||
|
Episode: false,
|
||||||
|
Video: false,
|
||||||
|
BoxSet: false,
|
||||||
|
Playlist: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchFavoritesByType = useCallback(
|
||||||
|
async (
|
||||||
|
itemType: BaseItemKind,
|
||||||
|
startIndex: number = 0,
|
||||||
|
limit: number = 20,
|
||||||
|
) => {
|
||||||
|
const response = await getItemsApi(api as Api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
sortBy: ["SeriesSortName", "SortName"],
|
||||||
|
sortOrder: ["Ascending"],
|
||||||
|
filters: ["IsFavorite"],
|
||||||
|
recursive: true,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
collapseBoxSetItems: false,
|
||||||
|
excludeLocationTypes: ["Virtual"],
|
||||||
|
enableTotalRecordCount: false,
|
||||||
|
startIndex: startIndex,
|
||||||
|
limit: limit,
|
||||||
|
includeItemTypes: [itemType],
|
||||||
|
});
|
||||||
|
const items = response.data.Items || [];
|
||||||
|
|
||||||
|
if (startIndex === 0) {
|
||||||
|
setEmptyState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[itemType as FavoriteTypes]: items.length === 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[api, user],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEmptyState({
|
||||||
|
Series: false,
|
||||||
|
Movie: false,
|
||||||
|
Episode: false,
|
||||||
|
Video: false,
|
||||||
|
BoxSet: false,
|
||||||
|
Playlist: false,
|
||||||
|
});
|
||||||
|
}, [api, user]);
|
||||||
|
|
||||||
|
const areAllEmpty = () => {
|
||||||
|
const loadedCategories = Object.values(emptyState);
|
||||||
|
return (
|
||||||
|
loadedCategories.length > 0 &&
|
||||||
|
loadedCategories.every((isEmpty) => isEmpty)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFavoriteSeries = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Series", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteMovies = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Movie", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteEpisodes = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Episode", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteVideos = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Video", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoriteBoxsets = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
const fetchFavoritePlaylists = useCallback(
|
||||||
|
({ pageParam }: { pageParam: number }) =>
|
||||||
|
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
||||||
|
[fetchFavoritesByType, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (areAllEmpty()) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
marginBottom: 16,
|
||||||
|
tintColor: Colors.primary,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
source={heart}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("favorites.noDataTitle")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: 18,
|
||||||
|
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);
|
||||||
@@ -687,3 +690,11 @@ export const Home = () => {
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Exported component that renders TV or mobile version based on platform
|
||||||
|
export const Home = () => {
|
||||||
|
if (Platform.isTV && HomeTV) {
|
||||||
|
return <HomeTV />;
|
||||||
|
}
|
||||||
|
return <HomeMobile />;
|
||||||
|
};
|
||||||
|
|||||||
754
components/home/Home.tv.tsx
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type {
|
||||||
|
BaseItemDto,
|
||||||
|
BaseItemDtoQueryResult,
|
||||||
|
BaseItemKind,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
getItemsApi,
|
||||||
|
getSuggestionsApi,
|
||||||
|
getTvShowsApi,
|
||||||
|
getUserLibraryApi,
|
||||||
|
getUserViewsApi,
|
||||||
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||||
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
// Reduced gap since sections have internal padding for scale animations
|
||||||
|
const SECTION_GAP = 10;
|
||||||
|
|
||||||
|
type InfiniteScrollingCollectionListSection = {
|
||||||
|
type: "InfiniteScrollingCollectionList";
|
||||||
|
title?: string;
|
||||||
|
queryKey: (string | undefined | null)[];
|
||||||
|
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
pageSize?: number;
|
||||||
|
priority?: 1 | 2;
|
||||||
|
parentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = InfiniteScrollingCollectionListSection;
|
||||||
|
|
||||||
|
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
const _router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
serverConnected,
|
||||||
|
loading: retryLoading,
|
||||||
|
retryCheck,
|
||||||
|
} = useNetworkStatus();
|
||||||
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Dynamic backdrop state with debounce
|
||||||
|
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Handle item focus with debounce
|
||||||
|
const handleItemFocus = useCallback((item: BaseItemDto) => {
|
||||||
|
// Clear any pending debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
// Set new timer to update focused item after debounce delay
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setFocusedItem(item);
|
||||||
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get backdrop URL from focused item (only if setting is enabled)
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (!settings.showHomeBackdrop || !focusedItem) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: focusedItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, focusedItem, settings.showHomeBackdrop]);
|
||||||
|
|
||||||
|
// Crossfade animation for backdrop transitions
|
||||||
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||||
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||||
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||||
|
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
// Prefetch the image before starting the crossfade
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
// Determine which layer to fade in
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
// Set the new URL on the incoming layer
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure image component has the new URL
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
// Crossfade: fade in the incoming layer, fade out the outgoing
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(incomingOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(outgoingOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [backdropUrl]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isError: e1,
|
||||||
|
isLoading: l1,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["home", "userViews", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!api || !user?.Id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getUserViewsApi(api).getUserViews({
|
||||||
|
userId: user.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
enabled: !!api && !!user?.Id,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userViews = useMemo(
|
||||||
|
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||||
|
[data, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = useMemo(() => {
|
||||||
|
const allow = ["movies", "tvshows"];
|
||||||
|
return (
|
||||||
|
userViews?.filter(
|
||||||
|
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
}, [userViews]);
|
||||||
|
|
||||||
|
const createCollectionConfig = useCallback(
|
||||||
|
(
|
||||||
|
title: string,
|
||||||
|
queryKey: string[],
|
||||||
|
includeItemTypes: BaseItemKind[],
|
||||||
|
parentId: string | undefined,
|
||||||
|
pageSize = 10,
|
||||||
|
): InfiniteScrollingCollectionListSection => ({
|
||||||
|
title,
|
||||||
|
queryKey,
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
if (!api) return [];
|
||||||
|
const allData =
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
limit: 10,
|
||||||
|
fields: ["PrimaryImageAspectRatio"],
|
||||||
|
imageTypeLimit: 1,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
pageSize,
|
||||||
|
parentId,
|
||||||
|
}),
|
||||||
|
[api, user?.Id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
|
const latestMediaViews = collections.map((c) => {
|
||||||
|
const includeItemTypes: BaseItemKind[] =
|
||||||
|
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||||
|
? []
|
||||||
|
: ["Movie"];
|
||||||
|
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||||
|
const queryKey: string[] = [
|
||||||
|
"home",
|
||||||
|
`recentlyAddedIn${c.CollectionType}`,
|
||||||
|
user.Id!,
|
||||||
|
c.Id!,
|
||||||
|
];
|
||||||
|
return createCollectionConfig(
|
||||||
|
title || "",
|
||||||
|
queryKey,
|
||||||
|
includeItemTypes,
|
||||||
|
c.Id,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||||
|
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||||
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (!item.Id || seen.has(item.Id)) return false;
|
||||||
|
seen.add(item.Id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("home.continue_and_next_up"),
|
||||||
|
queryKey: ["home", "continueAndNextUp"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||||
|
getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: 0,
|
||||||
|
limit: 20,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resumeItems = resumeResponse.data.Items || [];
|
||||||
|
const nextUpItems = nextUpResponse.data.Items || [];
|
||||||
|
|
||||||
|
const combined = [...resumeItems, ...nextUpItems];
|
||||||
|
const sorted = sortByRecentActivity(combined);
|
||||||
|
const deduplicated = deduplicateById(sorted);
|
||||||
|
|
||||||
|
return deduplicated.slice(pageParam, pageParam + 10);
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
title: t("home.continue_watching"),
|
||||||
|
queryKey: ["home", "resumeItems"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getItemsApi(api).getResumeItems({
|
||||||
|
userId: user.Id,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("home.next_up"),
|
||||||
|
queryKey: ["home", "nextUp-all"],
|
||||||
|
queryFn: async ({ pageParam = 0 }) =>
|
||||||
|
(
|
||||||
|
await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: false,
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: "horizontal",
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ss: Section[] = [
|
||||||
|
...firstSections,
|
||||||
|
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||||
|
...(!settings?.streamyStatsMovieRecommendations
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t("home.suggested_movies"),
|
||||||
|
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||||
|
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||||
|
(
|
||||||
|
await getSuggestionsApi(api).getSuggestions({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: 10,
|
||||||
|
mediaType: ["Video"],
|
||||||
|
type: ["Movie"],
|
||||||
|
})
|
||||||
|
).data.Items || [],
|
||||||
|
type: "InfiniteScrollingCollectionList" as const,
|
||||||
|
orientation: "vertical" as const,
|
||||||
|
pageSize: 10,
|
||||||
|
priority: 2 as const,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
return ss;
|
||||||
|
}, [
|
||||||
|
api,
|
||||||
|
user?.Id,
|
||||||
|
collections,
|
||||||
|
t,
|
||||||
|
createCollectionConfig,
|
||||||
|
settings?.streamyStatsMovieRecommendations,
|
||||||
|
settings.mergeNextUpAndContinueWatching,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const customSections = useMemo(() => {
|
||||||
|
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||||
|
const ss: Section[] = [];
|
||||||
|
settings.home.sections.forEach((section, index) => {
|
||||||
|
const id = section.title || `section-${index}`;
|
||||||
|
const pageSize = 10;
|
||||||
|
ss.push({
|
||||||
|
title: t(`${id}`),
|
||||||
|
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||||
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
|
if (section.items) {
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: section.items?.limit || pageSize,
|
||||||
|
recursive: true,
|
||||||
|
includeItemTypes: section.items?.includeItemTypes,
|
||||||
|
sortBy: section.items?.sortBy,
|
||||||
|
sortOrder: section.items?.sortOrder,
|
||||||
|
filters: section.items?.filters,
|
||||||
|
parentId: section.items?.parentId,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.nextUp) {
|
||||||
|
const response = await getTvShowsApi(api).getNextUp({
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: section.nextUp?.limit || pageSize,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
enableResumable: section.nextUp?.enableResumable,
|
||||||
|
enableRewatching: section.nextUp?.enableRewatching,
|
||||||
|
});
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
if (section.latest) {
|
||||||
|
const allData =
|
||||||
|
(
|
||||||
|
await getUserLibraryApi(api).getLatestMedia({
|
||||||
|
userId: user?.Id,
|
||||||
|
includeItemTypes: section.latest?.includeItemTypes,
|
||||||
|
limit: section.latest?.limit || 10,
|
||||||
|
isPlayed: section.latest?.isPlayed,
|
||||||
|
groupItems: section.latest?.groupItems,
|
||||||
|
})
|
||||||
|
).data || [];
|
||||||
|
|
||||||
|
return allData.slice(pageParam, pageParam + pageSize);
|
||||||
|
}
|
||||||
|
if (section.custom) {
|
||||||
|
const response = await api.get<BaseItemDtoQueryResult>(
|
||||||
|
section.custom.endpoint,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
...(section.custom.query || {}),
|
||||||
|
userId: user?.Id,
|
||||||
|
startIndex: pageParam,
|
||||||
|
limit: pageSize,
|
||||||
|
},
|
||||||
|
headers: section.custom.headers || {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.Items || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
type: "InfiniteScrollingCollectionList",
|
||||||
|
orientation: section?.orientation || "vertical",
|
||||||
|
pageSize,
|
||||||
|
priority: index < 2 ? 1 : 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return ss;
|
||||||
|
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||||
|
|
||||||
|
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||||
|
|
||||||
|
const highPrioritySectionKeys = useMemo(() => {
|
||||||
|
return sections
|
||||||
|
.filter((s) => s.priority === 1)
|
||||||
|
.map((s) => s.queryKey.join("-"));
|
||||||
|
}, [sections]);
|
||||||
|
|
||||||
|
const allHighPriorityLoaded = useMemo(() => {
|
||||||
|
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
|
||||||
|
}, [highPrioritySectionKeys, loadedSections]);
|
||||||
|
|
||||||
|
const markSectionLoaded = useCallback(
|
||||||
|
(queryKey: (string | undefined | null)[]) => {
|
||||||
|
const key = queryKey.join("-");
|
||||||
|
setLoadedSections((prev) => new Set(prev).add(key));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isConnected || serverConnected !== true) {
|
||||||
|
let title = "";
|
||||||
|
let subtitle = "";
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
title = t("home.no_internet");
|
||||||
|
subtitle = t("home.no_internet_message");
|
||||||
|
} else if (serverConnected === null) {
|
||||||
|
title = t("home.checking_server_connection");
|
||||||
|
subtitle = t("home.checking_server_connection_message");
|
||||||
|
} else if (!serverConnected) {
|
||||||
|
title = t("home.server_unreachable");
|
||||||
|
subtitle = t("home.server_unreachable_message");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 24 }}>
|
||||||
|
<Button
|
||||||
|
color='black'
|
||||||
|
onPress={retryCheck}
|
||||||
|
justify='center'
|
||||||
|
iconRight={
|
||||||
|
retryLoading ? null : (
|
||||||
|
<Ionicons name='refresh' size={24} color='white' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{retryLoading ? (
|
||||||
|
<ActivityIndicator size='small' color='white' />
|
||||||
|
) : (
|
||||||
|
t("home.retry")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e1)
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.oops")}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.error_message")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (l1)
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Dynamic backdrop with crossfade */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.4, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
// Render Streamystats sections after Continue Watching and Next Up
|
||||||
|
// When merged, they appear after index 0; otherwise after index 1
|
||||||
|
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||||
|
? 0
|
||||||
|
: 1;
|
||||||
|
const hasStreamystatsContent =
|
||||||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
|
settings.streamyStatsPromotedWatchlists;
|
||||||
|
const streamystatsSections =
|
||||||
|
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||||
|
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
|
||||||
|
{settings.streamyStatsMovieRecommendations && (
|
||||||
|
<StreamystatsRecommendations
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.recommended_movies",
|
||||||
|
)}
|
||||||
|
type='Movie'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.streamyStatsSeriesRecommendations && (
|
||||||
|
<StreamystatsRecommendations
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.streamystats.recommended_series",
|
||||||
|
)}
|
||||||
|
type='Series'
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{settings.streamyStatsPromotedWatchlists && (
|
||||||
|
<StreamystatsPromotedWatchlists
|
||||||
|
enabled={allHighPriorityLoaded}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
|
const isHighPriority = section.priority === 1;
|
||||||
|
const isFirstSection = index === 0;
|
||||||
|
return (
|
||||||
|
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||||
|
<InfiniteScrollingCollectionList
|
||||||
|
title={section.title}
|
||||||
|
queryKey={section.queryKey}
|
||||||
|
queryFn={section.queryFn}
|
||||||
|
orientation={section.orientation}
|
||||||
|
hideIfEmpty
|
||||||
|
pageSize={section.pageSize}
|
||||||
|
enabled={isHighPriority || allHighPriorityLoaded}
|
||||||
|
onLoaded={
|
||||||
|
isHighPriority
|
||||||
|
? () => markSectionLoaded(section.queryKey)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isFirstSection={isFirstSection}
|
||||||
|
onItemFocus={handleItemFocus}
|
||||||
|
parentId={section.parentId}
|
||||||
|
/>
|
||||||
|
{streamystatsSections}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
488
components/home/InfiniteScrollingCollectionList.tv.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import {
|
||||||
|
type QueryFunction,
|
||||||
|
type QueryKey,
|
||||||
|
useInfiniteQuery,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { useSegments } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
View,
|
||||||
|
type ViewProps,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
|
import ContinueWatchingPoster, {
|
||||||
|
TV_LANDSCAPE_WIDTH,
|
||||||
|
} from "../ContinueWatchingPoster.tv";
|
||||||
|
import SeriesPoster from "../posters/SeriesPoster.tv";
|
||||||
|
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title?: string | null;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
disabled?: boolean;
|
||||||
|
queryKey: QueryKey;
|
||||||
|
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||||
|
hideIfEmpty?: boolean;
|
||||||
|
pageSize?: number;
|
||||||
|
onPressSeeAll?: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
onLoaded?: () => void;
|
||||||
|
isFirstSection?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV-specific ItemCardText with larger fonts
|
||||||
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TV-specific "See All" card for end of lists
|
||||||
|
const TVSeeAllCard: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
orientation: "horizontal" | "vertical";
|
||||||
|
disabled?: boolean;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const width =
|
||||||
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||||
|
const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ width }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
aspectRatio,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='arrow-forward'
|
||||||
|
size={32}
|
||||||
|
color='white'
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 18, color: "#FFFFFF", fontWeight: "600" }}>
|
||||||
|
{t("common.seeAll", { defaultValue: "See all" })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
orientation = "vertical",
|
||||||
|
disabled = false,
|
||||||
|
queryFn,
|
||||||
|
queryKey,
|
||||||
|
hideIfEmpty = false,
|
||||||
|
pageSize = 10,
|
||||||
|
enabled = true,
|
||||||
|
onLoaded,
|
||||||
|
isFirstSection = false,
|
||||||
|
onItemFocus,
|
||||||
|
parentId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const effectivePageSize = Math.max(1, pageSize);
|
||||||
|
const hasCalledOnLoaded = useRef(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
// Track focus within section and scroll back to start when leaving
|
||||||
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
|
const [focusedCount, setFocusedCount] = useState(0);
|
||||||
|
const prevFocusedCount = useRef(0);
|
||||||
|
const scrollBackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// When section loses all focus, scroll back to start (with debounce to avoid
|
||||||
|
// triggering during transient focus changes like infinite scroll loading)
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any pending scroll-back timer
|
||||||
|
if (scrollBackTimerRef.current) {
|
||||||
|
clearTimeout(scrollBackTimerRef.current);
|
||||||
|
scrollBackTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevFocusedCount.current > 0 && focusedCount === 0) {
|
||||||
|
// Debounce the scroll-back to avoid triggering during re-renders
|
||||||
|
scrollBackTimerRef.current = setTimeout(() => {
|
||||||
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
prevFocusedCount.current = focusedCount;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (scrollBackTimerRef.current) {
|
||||||
|
clearTimeout(scrollBackTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [focusedCount]);
|
||||||
|
|
||||||
|
const handleItemFocus = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
setFocusedCount((c) => c + 1);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleItemBlur = useCallback(() => {
|
||||||
|
setFocusedCount((c) => Math.max(0, c - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus handler for See All card (doesn't need item parameter)
|
||||||
|
const handleSeeAllFocus = useCallback(() => {
|
||||||
|
setFocusedCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isSuccess,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: queryKey,
|
||||||
|
queryFn: ({ pageParam = 0, ...context }) =>
|
||||||
|
queryFn({ ...context, queryKey, pageParam }),
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
if (lastPage.length < effectivePageSize) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||||
|
},
|
||||||
|
initialPageParam: 0,
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
||||||
|
hasCalledOnLoaded.current = true;
|
||||||
|
onLoaded();
|
||||||
|
}
|
||||||
|
}, [isSuccess, onLoaded]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const allItems = useMemo(() => {
|
||||||
|
const items = data?.pages.flat() ?? [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: BaseItemDto[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const id = item.Id;
|
||||||
|
if (!id) continue;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
deduped.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const itemWidth =
|
||||||
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEndReached = useCallback(() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
|
const handleSeeAllPress = useCallback(() => {
|
||||||
|
if (!parentId) return;
|
||||||
|
router.push({
|
||||||
|
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||||
|
params: {
|
||||||
|
libraryId: parentId,
|
||||||
|
sortBy: SortByOption.DateCreated,
|
||||||
|
sortOrder: SortOrderOption.Descending,
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}, [router, parentId]);
|
||||||
|
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: itemWidth + ITEM_GAP,
|
||||||
|
offset: (itemWidth + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[itemWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||||
|
const isFirstItem = isFirstSection && index === 0;
|
||||||
|
const isHorizontal = orientation === "horizontal";
|
||||||
|
|
||||||
|
const renderPoster = () => {
|
||||||
|
if (item.Type === "Episode" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode" && !isHorizontal) {
|
||||||
|
return <SeriesPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Series" && !isHorizontal) {
|
||||||
|
return <SeriesPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Series" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Program") {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "BoxSet" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "BoxSet" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Playlist" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Playlist" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Video" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Video" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
// Default fallback
|
||||||
|
return isHorizontal ? (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
) : (
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
hasTVPreferredFocus={isFirstItem}
|
||||||
|
onFocus={() => handleItemFocus(item)}
|
||||||
|
onBlur={handleItemBlur}
|
||||||
|
>
|
||||||
|
{renderPoster()}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
orientation,
|
||||||
|
isFirstSection,
|
||||||
|
itemWidth,
|
||||||
|
handleItemPress,
|
||||||
|
handleItemFocus,
|
||||||
|
handleItemBlur,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||||
|
if (disabled || !title) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
{/* Section Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading === false && allItems.length === 0 && (
|
||||||
|
<Text
|
||||||
|
style={{ color: "#737373", fontSize: 16, 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: 16,
|
||||||
|
}}
|
||||||
|
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",
|
||||||
|
marginLeft: itemWidth / 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: ITEM_GAP,
|
||||||
|
justifyContent: "center",
|
||||||
|
height: orientation === "horizontal" ? 191 : 315,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{parentId && allItems.length > 0 && (
|
||||||
|
<TVSeeAllCard
|
||||||
|
onPress={handleSeeAllPress}
|
||||||
|
orientation={orientation}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={handleSeeAllFocus}
|
||||||
|
onBlur={handleItemBlur}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
332
components/home/StreamystatsPromotedWatchlists.tv.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
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 useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
|
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WatchlistSectionProps extends ViewProps {
|
||||||
|
watchlist: StreamystatsWatchlist;
|
||||||
|
jellyfinServerId: string;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||||
|
watchlist,
|
||||||
|
jellyfinServerId,
|
||||||
|
onItemFocus,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const { data: items, isLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"watchlist",
|
||||||
|
watchlist.id,
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||||
|
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamystatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
||||||
|
watchlistId: watchlist.id,
|
||||||
|
jellyfinServerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemIds = watchlistDetail.data?.items;
|
||||||
|
if (!itemIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
ids: itemIds,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Boolean(settings?.streamyStatsServerUrl) &&
|
||||||
|
Boolean(api?.accessToken) &&
|
||||||
|
Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
onFocus={() => onItemFocus?.(item)}
|
||||||
|
hasTVPreferredFocus={false}
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleItemPress, onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||||
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StreamystatsPromotedWatchlists: React.FC<
|
||||||
|
StreamystatsPromotedWatchlistsProps
|
||||||
|
> = ({ enabled = true, onItemFocus, ...props }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
const streamyStatsEnabled = useMemo(() => {
|
||||||
|
return Boolean(settings?.streamyStatsServerUrl);
|
||||||
|
}, [settings?.streamyStatsServerUrl]);
|
||||||
|
|
||||||
|
const { data: serverInfo } = useQuery({
|
||||||
|
queryKey: ["jellyfin", "serverInfo"],
|
||||||
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyfinServerId = serverInfo?.Id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: watchlists,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"promotedWatchlists",
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
||||||
|
if (
|
||||||
|
!settings?.streamyStatsServerUrl ||
|
||||||
|
!api?.accessToken ||
|
||||||
|
!jellyfinServerId
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamystatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await streamystatsApi.getPromotedWatchlists({
|
||||||
|
jellyfinServerId,
|
||||||
|
includePreview: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
enabled &&
|
||||||
|
streamyStatsEnabled &&
|
||||||
|
Boolean(api?.accessToken) &&
|
||||||
|
Boolean(jellyfinServerId) &&
|
||||||
|
Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!streamyStatsEnabled) return null;
|
||||||
|
if (isError) return null;
|
||||||
|
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 16,
|
||||||
|
width: 128,
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{watchlists?.map((watchlist) => (
|
||||||
|
<WatchlistSection
|
||||||
|
key={watchlist.id}
|
||||||
|
watchlist={watchlist}
|
||||||
|
jellyfinServerId={jellyfinServerId!}
|
||||||
|
onItemFocus={onItemFocus}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
265
components/home/StreamystatsRecommendations.tv.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
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 useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||||
|
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
|
||||||
|
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
type: "Movie" | "Series";
|
||||||
|
limit?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||||
|
title,
|
||||||
|
type,
|
||||||
|
limit = 20,
|
||||||
|
enabled = true,
|
||||||
|
onItemFocus,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
|
||||||
|
const streamyStatsEnabled = useMemo(() => {
|
||||||
|
return Boolean(settings?.streamyStatsServerUrl);
|
||||||
|
}, [settings?.streamyStatsServerUrl]);
|
||||||
|
|
||||||
|
const { data: serverInfo } = useQuery({
|
||||||
|
queryKey: ["jellyfin", "serverInfo"],
|
||||||
|
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||||
|
if (!api) return null;
|
||||||
|
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jellyfinServerId = serverInfo?.Id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: recommendationIds,
|
||||||
|
isLoading: isLoadingRecommendations,
|
||||||
|
isError: isRecommendationsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"recommendations",
|
||||||
|
type,
|
||||||
|
jellyfinServerId,
|
||||||
|
settings?.streamyStatsServerUrl,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
if (
|
||||||
|
!settings?.streamyStatsServerUrl ||
|
||||||
|
!api?.accessToken ||
|
||||||
|
!jellyfinServerId
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamyStatsApi = createStreamystatsApi({
|
||||||
|
serverUrl: settings.streamyStatsServerUrl,
|
||||||
|
jellyfinToken: api.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await streamyStatsApi.getRecommendationIds(
|
||||||
|
jellyfinServerId,
|
||||||
|
type,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response as StreamystatsRecommendationsIdsResponse;
|
||||||
|
|
||||||
|
if (type === "Movie") {
|
||||||
|
return data.data.movies || [];
|
||||||
|
}
|
||||||
|
return data.data.series || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
enabled &&
|
||||||
|
streamyStatsEnabled &&
|
||||||
|
Boolean(api?.accessToken) &&
|
||||||
|
Boolean(jellyfinServerId) &&
|
||||||
|
Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: items,
|
||||||
|
isLoading: isLoadingItems,
|
||||||
|
isError: isItemsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"streamystats",
|
||||||
|
"recommendations",
|
||||||
|
"items",
|
||||||
|
type,
|
||||||
|
recommendationIds,
|
||||||
|
],
|
||||||
|
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||||
|
if (!api || !user?.Id || !recommendationIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
userId: user.Id,
|
||||||
|
ids: recommendationIds,
|
||||||
|
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
enabled:
|
||||||
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = isLoadingRecommendations || isLoadingItems;
|
||||||
|
const isError = isRecommendationsError || isItemsError;
|
||||||
|
|
||||||
|
const handleItemPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_POSTER_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_POSTER_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(item)}
|
||||||
|
onFocus={() => onItemFocus?.(item)}
|
||||||
|
hasTVPreferredFocus={false}
|
||||||
|
>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleItemPress, onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!streamyStatsEnabled) return null;
|
||||||
|
if (isError) return null;
|
||||||
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<View key={i} style={{ width: TV_POSTER_WIDTH }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={items}
|
||||||
|
keyExtractor={(item) => item.Id!}
|
||||||
|
renderItem={renderItem}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
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' />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
390
components/library/TVLibraries.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
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 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 [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: 32,
|
||||||
|
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: 18,
|
||||||
|
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 { 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") || [],
|
||||||
|
[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: 20, 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
174
components/library/TVLibraryCard.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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 { 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 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: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 12,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.Name}
|
||||||
|
</Text>
|
||||||
|
{itemsCount !== undefined && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
81
components/login/TVInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
type TextInputProps,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
interface TVInputProps extends TextInputProps {
|
||||||
|
label?: string;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVInput: React.FC<TVInputProps> = ({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
760
components/login/TVLogin.tsx
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
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,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVInput } from "@/components/login/TVInput";
|
||||||
|
import {
|
||||||
|
TVPreviousServersList,
|
||||||
|
TVServerActionSheet,
|
||||||
|
} from "@/components/login/TVPreviousServersList";
|
||||||
|
import { TVSaveAccountToggle } from "@/components/login/TVSaveAccountToggle";
|
||||||
|
import { TVServerCard } from "@/components/login/TVServerCard";
|
||||||
|
import { PasswordEntryModal } from "@/components/PasswordEntryModal";
|
||||||
|
import { PINEntryModal } from "@/components/PINEntryModal";
|
||||||
|
import { SaveAccountModal } from "@/components/SaveAccountModal";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
|
||||||
|
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 }> = ({
|
||||||
|
onPress,
|
||||||
|
label,
|
||||||
|
}) => {
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<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);
|
||||||
|
|
||||||
|
// Server discovery
|
||||||
|
const {
|
||||||
|
servers: discoveredServers,
|
||||||
|
isSearching,
|
||||||
|
startDiscovery,
|
||||||
|
} = useJellyfinDiscovery();
|
||||||
|
|
||||||
|
// 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(/\/$/, "");
|
||||||
|
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, backgroundColor: "#000000" }}>
|
||||||
|
<KeyboardAvoidingView behavior='padding' 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")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
/>
|
||||||
|
</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'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Save Account Toggle */}
|
||||||
|
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||||
|
<TVSaveAccountToggle
|
||||||
|
value={saveAccount}
|
||||||
|
onValueChange={setSaveAccount}
|
||||||
|
label={t("save_account.save_for_later")}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Connect Button */}
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
onPress={() => handleConnect(serverURL)}
|
||||||
|
loading={loadingServerCheck}
|
||||||
|
disabled={loadingServerCheck || !serverURL.trim()}
|
||||||
|
>
|
||||||
|
{t("server.connect_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Server Discovery */}
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<Button
|
||||||
|
onPress={startDiscovery}
|
||||||
|
color='black'
|
||||||
|
className='bg-neutral-800'
|
||||||
|
>
|
||||||
|
{isSearching
|
||||||
|
? t("server.searching")
|
||||||
|
: t("server.search_for_local_servers")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Discovered Servers */}
|
||||||
|
{discoveredServers.length > 0 && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("server.servers")}
|
||||||
|
</Text>
|
||||||
|
<View style={{ gap: 16 }}>
|
||||||
|
{discoveredServers.map((server) => (
|
||||||
|
<TVServerCard
|
||||||
|
key={server.address}
|
||||||
|
title={server.serverName || server.address}
|
||||||
|
subtitle={
|
||||||
|
server.serverName ? server.address : undefined
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
|
{/* Save Account Modal */}
|
||||||
|
<SaveAccountModal
|
||||||
|
visible={showSaveModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowSaveModal(false);
|
||||||
|
setPendingLogin(null);
|
||||||
|
}}
|
||||||
|
onSave={handleSaveAccountConfirm}
|
||||||
|
username={pendingLogin?.username || credentials.username}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* PIN Entry Modal */}
|
||||||
|
<PINEntryModal
|
||||||
|
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 */}
|
||||||
|
<PasswordEntryModal
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
508
components/login/TVPreviousServersList.tsx
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the action sheet for use in parent components
|
||||||
|
export { TVServerActionSheet };
|
||||||
|
|
||||||
|
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||||
|
onServerSelect,
|
||||||
|
onQuickLogin,
|
||||||
|
onAddAccount,
|
||||||
|
onPinRequired,
|
||||||
|
onPasswordRequired,
|
||||||
|
onServerAction,
|
||||||
|
loginServerOverride,
|
||||||
|
}) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
115
components/login/TVSaveAccountToggle.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
label,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
148
components/login/TVServerCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
securityIcon,
|
||||||
|
isLoading,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={isLoading}
|
||||||
|
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" : "#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={{
|
||||||
|
|||||||
530
components/persons/TVActorPage.tsx
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
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,
|
||||||
|
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 [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
|
||||||
|
// FlatList ref for scrolling back
|
||||||
|
const filmographyListRef = useRef<FlatList<BaseItemDto>>(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 filmography
|
||||||
|
const { data: filmography = [], isLoading: isLoadingFilmography } = useQuery({
|
||||||
|
queryKey: ["actor", "filmography", 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", "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(() => {
|
||||||
|
if (filmography.length === 0) return null;
|
||||||
|
const focusedItem = filmography[focusedIndex] ?? filmography[0];
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: focusedItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, filmography, focusedIndex]);
|
||||||
|
|
||||||
|
// 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 }) => (
|
||||||
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => handleItemPress(filmItem)}
|
||||||
|
onFocus={() => setFocusedIndex(index)}
|
||||||
|
hasTVPreferredFocus={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 */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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 section */}
|
||||||
|
<View style={{ flex: 1, overflow: "visible" }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.appeared_in")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoadingFilmography ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 300,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
) : filmography.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.no_results")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
ref={filmographyListRef}
|
||||||
|
horizontal
|
||||||
|
data={filmography}
|
||||||
|
keyExtractor={(filmItem) => filmItem.Id!}
|
||||||
|
renderItem={renderFilmographyItem}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={6}
|
||||||
|
maxToRenderPerBatch={4}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
components/posters/MoviePoster.tv.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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 { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_POSTER_WIDTH = 210;
|
||||||
|
|
||||||
|
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: 420, // 2x for quality on large screens
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
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;
|
||||||
69
components/posters/SeriesPoster.tv.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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 { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export const TV_POSTER_WIDTH = 210;
|
||||||
|
|
||||||
|
type SeriesPosterProps = {
|
||||||
|
item: BaseItemDto;
|
||||||
|
showProgress?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||||
|
}
|
||||||
|
return getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
width: 420, // 2x for quality on large screens
|
||||||
|
});
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const blurhash = useMemo(() => {
|
||||||
|
const key = item.ImageTags?.Primary as string;
|
||||||
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
aspectRatio: 10 / 15,
|
||||||
|
position: "relative",
|
||||||
|
borderRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
placeholder={{
|
||||||
|
blurhash,
|
||||||
|
}}
|
||||||
|
key={item.Id}
|
||||||
|
id={item.Id}
|
||||||
|
source={
|
||||||
|
url
|
||||||
|
? {
|
||||||
|
uri: url,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SeriesPoster;
|
||||||
54
components/search/TVSearchBadge.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVSearchBadgeProps {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
282
components/search/TVSearchPage.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { TVSearchBadge } from "./TVSearchBadge";
|
||||||
|
import { TVSearchSection } from "./TVSearchSection";
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 60;
|
||||||
|
const TOP_PADDING = 100;
|
||||||
|
const SECTION_GAP = 10;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
// Loading skeleton for TV
|
||||||
|
const TVLoadingSkeleton: React.FC = () => {
|
||||||
|
const itemWidth = 210;
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
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: 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: 16,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
Placeholder text here
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Example search suggestions for TV
|
||||||
|
const exampleSearches = [
|
||||||
|
"Lord of the rings",
|
||||||
|
"Avengers",
|
||||||
|
"Game of Thrones",
|
||||||
|
"Breaking Bad",
|
||||||
|
"Stranger Things",
|
||||||
|
"The Mandalorian",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface TVSearchPageProps {
|
||||||
|
search: string;
|
||||||
|
setSearch: (text: string) => void;
|
||||||
|
debouncedSearch: string;
|
||||||
|
movies?: BaseItemDto[];
|
||||||
|
series?: BaseItemDto[];
|
||||||
|
episodes?: BaseItemDto[];
|
||||||
|
collections?: BaseItemDto[];
|
||||||
|
actors?: BaseItemDto[];
|
||||||
|
artists?: BaseItemDto[];
|
||||||
|
albums?: BaseItemDto[];
|
||||||
|
songs?: BaseItemDto[];
|
||||||
|
playlists?: BaseItemDto[];
|
||||||
|
loading: boolean;
|
||||||
|
noResults: boolean;
|
||||||
|
onItemPress: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
debouncedSearch,
|
||||||
|
movies,
|
||||||
|
series,
|
||||||
|
episodes,
|
||||||
|
collections,
|
||||||
|
actors,
|
||||||
|
artists,
|
||||||
|
albums,
|
||||||
|
songs,
|
||||||
|
playlists,
|
||||||
|
loading,
|
||||||
|
noResults,
|
||||||
|
onItemPress,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
// Image URL getter for music items
|
||||||
|
const getImageUrl = useMemo(() => {
|
||||||
|
return (item: BaseItemDto): string | undefined => {
|
||||||
|
if (!api) return undefined;
|
||||||
|
const url = getPrimaryImageUrl({ api, item });
|
||||||
|
return url ?? undefined;
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// Determine which section should have initial focus
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const allSections: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
items: BaseItemDto[] | undefined;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
}[] = [
|
||||||
|
{ key: "movies", title: t("search.movies"), items: movies },
|
||||||
|
{ key: "series", title: t("search.series"), items: series },
|
||||||
|
{
|
||||||
|
key: "episodes",
|
||||||
|
title: t("search.episodes"),
|
||||||
|
items: episodes,
|
||||||
|
orientation: "horizontal" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "collections",
|
||||||
|
title: t("search.collections"),
|
||||||
|
items: collections,
|
||||||
|
},
|
||||||
|
{ key: "actors", title: t("search.actors"), items: actors },
|
||||||
|
{ key: "artists", title: t("search.artists"), items: artists },
|
||||||
|
{ key: "albums", title: t("search.albums"), items: albums },
|
||||||
|
{ key: "songs", title: t("search.songs"), items: songs },
|
||||||
|
{ key: "playlists", title: t("search.playlists"), items: playlists },
|
||||||
|
];
|
||||||
|
|
||||||
|
return allSections.filter((s) => s.items && s.items.length > 0);
|
||||||
|
}, [
|
||||||
|
movies,
|
||||||
|
series,
|
||||||
|
episodes,
|
||||||
|
collections,
|
||||||
|
actors,
|
||||||
|
artists,
|
||||||
|
albums,
|
||||||
|
songs,
|
||||||
|
playlists,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardDismissMode='on-drag'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Search Input */}
|
||||||
|
<View style={{ marginBottom: 32, marginHorizontal: SCALE_PADDING }}>
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
hasTVPreferredFocus={
|
||||||
|
debouncedSearch.length === 0 && sections.length === 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
<TVLoadingSkeleton />
|
||||||
|
<TVLoadingSkeleton />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{!loading && (
|
||||||
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<TVSearchSection
|
||||||
|
key={section.key}
|
||||||
|
title={section.title}
|
||||||
|
items={section.items!}
|
||||||
|
orientation={section.orientation || "vertical"}
|
||||||
|
isFirstSection={index === 0}
|
||||||
|
onItemPress={onItemPress}
|
||||||
|
imageUrlGetter={
|
||||||
|
["artists", "albums", "songs", "playlists"].includes(
|
||||||
|
section.key,
|
||||||
|
)
|
||||||
|
? getImageUrl
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Results State */}
|
||||||
|
{!loading && noResults && debouncedSearch.length > 0 && (
|
||||||
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("search.no_results_found_for")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, color: "#9334E9" }}>
|
||||||
|
"{debouncedSearch}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Example Searches (when no search query) */}
|
||||||
|
{!loading && debouncedSearch.length === 0 && (
|
||||||
|
<View style={{ alignItems: "center", paddingTop: 40, gap: 16 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exampleSearches.map((example) => (
|
||||||
|
<TVSearchBadge
|
||||||
|
key={example}
|
||||||
|
label={example}
|
||||||
|
onPress={() => setSearch(example)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
344
components/search/TVSearchSection.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { FlatList, View, type ViewProps } from "react-native";
|
||||||
|
import ContinueWatchingPoster, {
|
||||||
|
TV_LANDSCAPE_WIDTH,
|
||||||
|
} from "@/components/ContinueWatchingPoster.tv";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import MoviePoster, {
|
||||||
|
TV_POSTER_WIDTH,
|
||||||
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
// TV-specific ItemCardText with larger fonts
|
||||||
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
|
{item.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{item.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : item.Type === "MusicArtist" ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
) : item.Type === "MusicAlbum" ? (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : item.Type === "Audio" ? (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : item.Type === "Playlist" ? (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ChildCount} tracks
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : item.Type === "Person" ? (
|
||||||
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TVSearchSectionProps extends ViewProps {
|
||||||
|
title: string;
|
||||||
|
items: BaseItemDto[];
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
disabled?: boolean;
|
||||||
|
isFirstSection?: boolean;
|
||||||
|
onItemPress: (item: BaseItemDto) => void;
|
||||||
|
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
orientation = "vertical",
|
||||||
|
disabled = false,
|
||||||
|
isFirstSection = false,
|
||||||
|
onItemPress,
|
||||||
|
imageUrlGetter,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
|
const [focusedCount, setFocusedCount] = useState(0);
|
||||||
|
const prevFocusedCount = useRef(0);
|
||||||
|
|
||||||
|
// When section loses all focus, scroll back to start
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFocusedCount.current > 0 && focusedCount === 0) {
|
||||||
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
}
|
||||||
|
prevFocusedCount.current = focusedCount;
|
||||||
|
}, [focusedCount]);
|
||||||
|
|
||||||
|
const handleItemFocus = useCallback(() => {
|
||||||
|
setFocusedCount((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleItemBlur = useCallback(() => {
|
||||||
|
setFocusedCount((c) => Math.max(0, c - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const itemWidth =
|
||||||
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
// Music Artist - circular avatar
|
||||||
|
if (item.Type === "MusicArtist") {
|
||||||
|
const imageUrl = imageUrlGetter?.(item);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 80,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 48 }}>👤</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Music Album, Audio, Playlist - square images
|
||||||
|
if (
|
||||||
|
item.Type === "MusicAlbum" ||
|
||||||
|
item.Type === "Audio" ||
|
||||||
|
item.Type === "Playlist"
|
||||||
|
) {
|
||||||
|
const imageUrl = imageUrlGetter?.(item);
|
||||||
|
const icon =
|
||||||
|
item.Type === "Playlist"
|
||||||
|
? "🎶"
|
||||||
|
: item.Type === "Audio"
|
||||||
|
? "🎵"
|
||||||
|
: "🎵";
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
height: TV_POSTER_WIDTH,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 64 }}>{icon}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Person (Actor)
|
||||||
|
if (item.Type === "Person") {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Episode rendering
|
||||||
|
if (item.Type === "Episode" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Episode" && !isHorizontal) {
|
||||||
|
return <SeriesPoster item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movie rendering
|
||||||
|
if (item.Type === "Movie" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Movie" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series rendering
|
||||||
|
if (item.Type === "Series" && !isHorizontal) {
|
||||||
|
return <SeriesPoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "Series" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoxSet (Collection)
|
||||||
|
if (item.Type === "BoxSet" && !isHorizontal) {
|
||||||
|
return <MoviePoster item={item} />;
|
||||||
|
}
|
||||||
|
if (item.Type === "BoxSet" && isHorizontal) {
|
||||||
|
return <ContinueWatchingPoster item={item} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return isHorizontal ? (
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
) : (
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Special width for music artists (circular)
|
||||||
|
const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={() => onItemPress(item)}
|
||||||
|
hasTVPreferredFocus={isFirstItem && !disabled}
|
||||||
|
onFocus={handleItemFocus}
|
||||||
|
onBlur={handleItemBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{renderPoster()}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
orientation,
|
||||||
|
isFirstSection,
|
||||||
|
itemWidth,
|
||||||
|
onItemPress,
|
||||||
|
handleItemFocus,
|
||||||
|
handleItemBlur,
|
||||||
|
disabled,
|
||||||
|
imageUrlGetter,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
|
{/* Section Header */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
141
components/series/TVEpisodeCard.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
export const TV_EPISODE_WIDTH = 340;
|
||||||
|
|
||||||
|
interface TVEpisodeCardProps {
|
||||||
|
episode: BaseItemDto;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||||
|
episode,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
onPress,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const thumbnailUrl = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
// Try to get episode primary image first
|
||||||
|
if (episode.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80&tag=${episode.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to series thumb or backdrop
|
||||||
|
if (episode.ParentBackdropItemId && episode.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${episode.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${episode.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default episode image
|
||||||
|
return `${api.basePath}/Items/${episode.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
|
}, [api, episode]);
|
||||||
|
|
||||||
|
const duration = useMemo(() => {
|
||||||
|
if (!episode.RunTimeTicks) return null;
|
||||||
|
return runtimeTicksToMinutes(episode.RunTimeTicks);
|
||||||
|
}, [episode.RunTimeTicks]);
|
||||||
|
|
||||||
|
const episodeLabel = useMemo(() => {
|
||||||
|
const season = episode.ParentIndexNumber;
|
||||||
|
const ep = episode.IndexNumber;
|
||||||
|
if (season !== undefined && ep !== undefined) {
|
||||||
|
return `S${season}:E${ep}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ width: TV_EPISODE_WIDTH }}>
|
||||||
|
<TVFocusablePoster
|
||||||
|
onPress={onPress}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: TV_EPISODE_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#262626",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: thumbnailUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
cachePolicy='memory-disk'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!episode.UserData?.Played && <WatchedIndicator item={episode} />}
|
||||||
|
<ProgressBar item={episode} />
|
||||||
|
</View>
|
||||||
|
</TVFocusablePoster>
|
||||||
|
|
||||||
|
{/* Episode info below thumbnail */}
|
||||||
|
<View style={{ marginTop: 12, paddingHorizontal: 4 }}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
{episodeLabel && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{episodeLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: "#6B7280", fontSize: 14 }}>•</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: "#9CA3AF" }}>{duration}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginTop: 4,
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{episode.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
195
components/series/TVSeasonSelector.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface TVSeasonSelectorProps {
|
||||||
|
visible: boolean;
|
||||||
|
seasons: BaseItemDto[];
|
||||||
|
selectedSeasonIndex: number | string | null | undefined;
|
||||||
|
onSelect: (seasonIndex: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TVSeasonCard: React.FC<{
|
||||||
|
season: BaseItemDto;
|
||||||
|
isSelected: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}> = ({ season, isSelected, 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 seasonName = useMemo(() => {
|
||||||
|
if (season.Name) return season.Name;
|
||||||
|
if (season.IndexNumber !== undefined) return `Season ${season.IndexNumber}`;
|
||||||
|
return "Season";
|
||||||
|
}, [season]);
|
||||||
|
|
||||||
|
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: 85,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: isSelected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused || isSelected ? "600" : "400",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{seasonName}
|
||||||
|
</Text>
|
||||||
|
{isSelected && !focused && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
right: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={18}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSeasonSelector: React.FC<TVSeasonSelectorProps> = ({
|
||||||
|
visible,
|
||||||
|
seasons,
|
||||||
|
selectedSeasonIndex,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const initialFocusIndex = useMemo(() => {
|
||||||
|
const idx = seasons.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={80}
|
||||||
|
tint='dark'
|
||||||
|
style={{
|
||||||
|
borderTopLeftRadius: 24,
|
||||||
|
borderTopRightRadius: 24,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 50,
|
||||||
|
overflow: "visible",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Horizontal season cards */}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
paddingVertical: 10,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasons.map((season, index) => (
|
||||||
|
<TVSeasonCard
|
||||||
|
key={season.Id || index}
|
||||||
|
season={season}
|
||||||
|
isSelected={
|
||||||
|
season.IndexNumber === selectedSeasonIndex ||
|
||||||
|
season.Name === String(selectedSeasonIndex)
|
||||||
|
}
|
||||||
|
hasTVPreferredFocus={index === initialFocusIndex}
|
||||||
|
onPress={() => {
|
||||||
|
onSelect(season.IndexNumber ?? index);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
components/series/TVSeriesHeader.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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 React, { useMemo } from "react";
|
||||||
|
import { Dimensions, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
|
interface TVSeriesHeaderProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (!api || !item) return null;
|
||||||
|
return getLogoImageUrlById({ api, item });
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const yearString = useMemo(() => {
|
||||||
|
const startYear = item.StartDate
|
||||||
|
? new Date(item.StartDate).getFullYear()
|
||||||
|
: item.ProductionYear;
|
||||||
|
|
||||||
|
const endYear = item.EndDate ? new Date(item.EndDate).getFullYear() : null;
|
||||||
|
|
||||||
|
if (startYear && endYear) {
|
||||||
|
if (startYear === endYear) return String(startYear);
|
||||||
|
return `${startYear} - ${endYear}`;
|
||||||
|
}
|
||||||
|
if (startYear) return String(startYear);
|
||||||
|
return null;
|
||||||
|
}, [item.StartDate, item.EndDate, item.ProductionYear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: "80%",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 52,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges row */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{yearString && (
|
||||||
|
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{yearString}</Text>
|
||||||
|
)}
|
||||||
|
{item.OfficialRating && (
|
||||||
|
<Badge text={item.OfficialRating} variant='gray' />
|
||||||
|
)}
|
||||||
|
{item.CommunityRating != null && (
|
||||||
|
<Badge
|
||||||
|
text={item.CommunityRating.toFixed(1)}
|
||||||
|
variant='gray'
|
||||||
|
iconLeft={<Ionicons name='star' size={16} color='gold' />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{item.Genres && item.Genres.length > 0 && (
|
||||||
|
<View style={{ marginBottom: 24 }}>
|
||||||
|
<GenreTags genres={item.Genres} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview */}
|
||||||
|
{item.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#D1D5DB",
|
||||||
|
lineHeight: 28,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.45,
|
||||||
|
}}
|
||||||
|
numberOfLines={4}
|
||||||
|
>
|
||||||
|
{item.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
635
components/series/TVSeriesPage.tsx
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
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 { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
|
||||||
|
import {
|
||||||
|
TV_EPISODE_WIDTH,
|
||||||
|
TVEpisodeCard,
|
||||||
|
} from "@/components/series/TVEpisodeCard";
|
||||||
|
import { TVSeasonSelector } from "@/components/series/TVSeasonSelector";
|
||||||
|
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||||
|
import {
|
||||||
|
buildOfflineSeasons,
|
||||||
|
getDownloadedEpisodesForSeason,
|
||||||
|
} from "@/utils/downloads/offline-series";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
|
||||||
|
const HORIZONTAL_PADDING = 80;
|
||||||
|
const TOP_PADDING = 140;
|
||||||
|
const POSTER_WIDTH_PERCENT = 0.22;
|
||||||
|
const ITEM_GAP = 16;
|
||||||
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
|
interface TVSeriesPageProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
allEpisodes?: BaseItemDto[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focusable button component for TV
|
||||||
|
const TVFocusableButton: React.FC<{
|
||||||
|
onPress: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
}> = ({
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
disabled = false,
|
||||||
|
variant = "primary",
|
||||||
|
}) => {
|
||||||
|
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 isPrimary = variant === "primary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.05);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: isPrimary ? "#fff" : "#a855f7",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused
|
||||||
|
? isPrimary
|
||||||
|
? "#ffffff"
|
||||||
|
: "#7c3aed"
|
||||||
|
: isPrimary
|
||||||
|
? "rgba(255, 255, 255, 0.9)"
|
||||||
|
: "rgba(124, 58, 237, 0.8)",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 180,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Season selector button
|
||||||
|
const TVSeasonButton: React.FC<{
|
||||||
|
seasonName: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}> = ({ seasonName, onPress, disabled = false }) => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = (v: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: v,
|
||||||
|
duration: 120,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.02);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasonName}
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name='chevron-down'
|
||||||
|
size={18}
|
||||||
|
color={focused ? "#000" : "#FFFFFF"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||||
|
item,
|
||||||
|
allEpisodes = [],
|
||||||
|
isLoading: _isLoading,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const segments = useSegments();
|
||||||
|
const from = (segments as string[])[2] || "(home)";
|
||||||
|
const isOffline = useOfflineMode();
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||||
|
|
||||||
|
// Season state
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||||
|
const selectedSeasonIndex = useMemo(
|
||||||
|
() => seasonIndexState[item.Id ?? ""] ?? 1,
|
||||||
|
[item.Id, seasonIndexState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [openModal, setOpenModal] = useState<"season" | null>(null);
|
||||||
|
const isModalOpen = openModal !== null;
|
||||||
|
|
||||||
|
// ScrollView ref for page scrolling
|
||||||
|
const mainScrollRef = useRef<ScrollView>(null);
|
||||||
|
// FlatList ref for scrolling back
|
||||||
|
const episodeListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
|
const [focusedCount, setFocusedCount] = useState(0);
|
||||||
|
const prevFocusedCount = useRef(0);
|
||||||
|
|
||||||
|
// Scroll back to start when episode list loses focus
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFocusedCount.current > 0 && focusedCount === 0) {
|
||||||
|
episodeListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
// Scroll page back to top when leaving episode section
|
||||||
|
mainScrollRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
}
|
||||||
|
prevFocusedCount.current = focusedCount;
|
||||||
|
}, [focusedCount]);
|
||||||
|
|
||||||
|
const handleEpisodeFocus = useCallback(() => {
|
||||||
|
setFocusedCount((c) => {
|
||||||
|
// Scroll page down when first episode receives focus
|
||||||
|
if (c === 0) {
|
||||||
|
mainScrollRef.current?.scrollTo({ y: 200, animated: true });
|
||||||
|
}
|
||||||
|
return c + 1;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEpisodeBlur = useCallback(() => {
|
||||||
|
setFocusedCount((c) => Math.max(0, c - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch seasons
|
||||||
|
const { data: seasons = [] } = useQuery({
|
||||||
|
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline) {
|
||||||
|
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id || !item.Id) return [];
|
||||||
|
|
||||||
|
const response = await api.axiosInstance.get(
|
||||||
|
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
userId: user.Id,
|
||||||
|
itemId: item.Id,
|
||||||
|
Fields: "ItemCounts,PrimaryImageAspectRatio",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get selected season ID
|
||||||
|
const selectedSeasonId = useMemo(() => {
|
||||||
|
const season = seasons.find(
|
||||||
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return season?.Id ?? null;
|
||||||
|
}, [seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
// Get selected season number for offline mode
|
||||||
|
const selectedSeasonNumber = useMemo(() => {
|
||||||
|
if (!isOffline) return null;
|
||||||
|
const season = seasons.find(
|
||||||
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return season?.IndexNumber ?? null;
|
||||||
|
}, [isOffline, seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
// Fetch episodes for selected season
|
||||||
|
const { data: episodesForSeason = [] } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"episodes",
|
||||||
|
item.Id,
|
||||||
|
isOffline ? selectedSeasonNumber : selectedSeasonId,
|
||||||
|
isOffline,
|
||||||
|
downloadedItems.length,
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (isOffline) {
|
||||||
|
return getDownloadedEpisodesForSeason(
|
||||||
|
getDownloadedItems(),
|
||||||
|
item.Id!,
|
||||||
|
selectedSeasonNumber!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||||
|
|
||||||
|
const res = await getTvShowsApi(api).getEpisodes({
|
||||||
|
seriesId: item.Id,
|
||||||
|
userId: user.Id,
|
||||||
|
seasonId: selectedSeasonId,
|
||||||
|
enableUserData: true,
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
|
});
|
||||||
|
return res.data.Items || [];
|
||||||
|
},
|
||||||
|
staleTime: isOffline ? Infinity : 0,
|
||||||
|
enabled: isOffline
|
||||||
|
? !!item.Id && selectedSeasonNumber !== null
|
||||||
|
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find next unwatched episode
|
||||||
|
const nextUnwatchedEpisode = useMemo(() => {
|
||||||
|
// First check all episodes for a "next up" candidate
|
||||||
|
for (const ep of allEpisodes) {
|
||||||
|
if (!ep.UserData?.Played) {
|
||||||
|
// Check if it has progress (continue watching)
|
||||||
|
if ((ep.UserData?.PlaybackPositionTicks ?? 0) > 0) {
|
||||||
|
return ep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first unwatched
|
||||||
|
return allEpisodes.find((ep) => !ep.UserData?.Played) || allEpisodes[0];
|
||||||
|
}, [allEpisodes]);
|
||||||
|
|
||||||
|
// Get season name for button
|
||||||
|
const selectedSeasonName = useMemo(() => {
|
||||||
|
const season = seasons.find(
|
||||||
|
(s: BaseItemDto) =>
|
||||||
|
s.IndexNumber === selectedSeasonIndex ||
|
||||||
|
s.Name === String(selectedSeasonIndex),
|
||||||
|
);
|
||||||
|
return season?.Name || `Season ${selectedSeasonIndex}`;
|
||||||
|
}, [seasons, selectedSeasonIndex]);
|
||||||
|
|
||||||
|
// Handle episode press
|
||||||
|
const handleEpisodePress = useCallback(
|
||||||
|
(episode: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(episode, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[from, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle play next episode
|
||||||
|
const handlePlayNextEpisode = useCallback(() => {
|
||||||
|
if (nextUnwatchedEpisode) {
|
||||||
|
const navigation = getItemNavigation(nextUnwatchedEpisode, from);
|
||||||
|
router.push(navigation as any);
|
||||||
|
}
|
||||||
|
}, [nextUnwatchedEpisode, from, router]);
|
||||||
|
|
||||||
|
// Handle season selection
|
||||||
|
const handleSeasonSelect = useCallback(
|
||||||
|
(seasonIdx: number) => {
|
||||||
|
if (!item.Id) return;
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.Id!]: seasonIdx,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[item.Id, setSeasonIndexState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Episode list item layout
|
||||||
|
const getItemLayout = useCallback(
|
||||||
|
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||||
|
length: TV_EPISODE_WIDTH + ITEM_GAP,
|
||||||
|
offset: (TV_EPISODE_WIDTH + ITEM_GAP) * index,
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render episode card
|
||||||
|
const renderEpisode = useCallback(
|
||||||
|
({ item: episode }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<View style={{ marginRight: ITEM_GAP }}>
|
||||||
|
<TVEpisodeCard
|
||||||
|
episode={episode}
|
||||||
|
onPress={() => handleEpisodePress(episode)}
|
||||||
|
disabled={isModalOpen}
|
||||||
|
onFocus={handleEpisodeFocus}
|
||||||
|
onBlur={handleEpisodeBlur}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
[handleEpisodePress, isModalOpen, handleEpisodeFocus, handleEpisodeBlur],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get play button text
|
||||||
|
const playButtonText = useMemo(() => {
|
||||||
|
if (!nextUnwatchedEpisode) return t("common.play");
|
||||||
|
|
||||||
|
const season = nextUnwatchedEpisode.ParentIndexNumber;
|
||||||
|
const episode = nextUnwatchedEpisode.IndexNumber;
|
||||||
|
const hasProgress =
|
||||||
|
(nextUnwatchedEpisode.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasProgress) {
|
||||||
|
return `${t("home.continue")} S${season}:E${episode}`;
|
||||||
|
}
|
||||||
|
return `${t("common.play")} S${season}:E${episode}`;
|
||||||
|
}, [nextUnwatchedEpisode, t]);
|
||||||
|
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
|
{/* Full-screen backdrop */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
variant='Backdrop'
|
||||||
|
item={item}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
{/* 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 */}
|
||||||
|
<ScrollView
|
||||||
|
ref={mainScrollRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
}}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Top section - Poster + Content */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left side - Poster */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: SCREEN_WIDTH * POSTER_WIDTH_PERCENT,
|
||||||
|
marginRight: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
shadowOpacity: 0.5,
|
||||||
|
shadowRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
variant='Primary'
|
||||||
|
item={item}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
<TVSeriesHeader item={item} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusableButton
|
||||||
|
onPress={handlePlayNextEpisode}
|
||||||
|
hasTVPreferredFocus
|
||||||
|
disabled={isModalOpen}
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='play'
|
||||||
|
size={28}
|
||||||
|
color='#000000'
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{playButtonText}
|
||||||
|
</Text>
|
||||||
|
</TVFocusableButton>
|
||||||
|
|
||||||
|
{seasons.length > 1 && (
|
||||||
|
<TVSeasonButton
|
||||||
|
seasonName={selectedSeasonName}
|
||||||
|
onPress={() => setOpenModal("season")}
|
||||||
|
disabled={isModalOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episodes section */}
|
||||||
|
<View style={{ marginTop: 40, overflow: "visible" }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedSeasonName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
ref={episodeListRef}
|
||||||
|
horizontal
|
||||||
|
data={episodesForSeason}
|
||||||
|
keyExtractor={(ep) => ep.Id!}
|
||||||
|
renderItem={renderEpisode}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
initialNumToRender={5}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
windowSize={5}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingVertical: SCALE_PADDING,
|
||||||
|
paddingHorizontal: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: "#737373",
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: SCALE_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.no_episodes_for_this_season")}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Season selector modal */}
|
||||||
|
<TVSeasonSelector
|
||||||
|
visible={openModal === "season"}
|
||||||
|
seasons={seasons}
|
||||||
|
selectedSeasonIndex={selectedSeasonIndex}
|
||||||
|
onSelect={handleSeasonSelect}
|
||||||
|
onClose={() => setOpenModal(null)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -23,6 +24,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
|
settings?.openSubtitlesApiKey || "",
|
||||||
|
);
|
||||||
|
|
||||||
const subtitleModes = [
|
const subtitleModes = [
|
||||||
SubtitlePlaybackMode.Default,
|
SubtitlePlaybackMode.Default,
|
||||||
SubtitlePlaybackMode.Smart,
|
SubtitlePlaybackMode.Smart,
|
||||||
@@ -171,6 +177,44 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
|
|
||||||
|
{/* OpenSubtitles API Key for client-side subtitle fetching */}
|
||||||
|
<ListGroup
|
||||||
|
title={
|
||||||
|
t("home.settings.subtitles.opensubtitles_title") || "OpenSubtitles"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Text className='text-[#8E8D91] text-xs'>
|
||||||
|
{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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='p-4'>
|
||||||
|
<Text className='text-xs text-gray-400 mb-2'>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
className='border border-neutral-800'
|
||||||
|
placeholder={
|
||||||
|
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
|
||||||
|
"Enter API key..."
|
||||||
|
}
|
||||||
|
value={openSubtitlesApiKey}
|
||||||
|
onChangeText={setOpenSubtitlesApiKey}
|
||||||
|
onBlur={() => {
|
||||||
|
updateSettings({ openSubtitlesApiKey });
|
||||||
|
}}
|
||||||
|
autoCapitalize='none'
|
||||||
|
autoCorrect={false}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Text className='text-xs text-gray-500 mt-2'>
|
||||||
|
{t("home.settings.subtitles.opensubtitles_get_key") ||
|
||||||
|
"Get your free API key at opensubtitles.com/en/consumers"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type ICommonScreenOptions =
|
|||||||
|
|
||||||
export const commonScreenOptions: ICommonScreenOptions = {
|
export const commonScreenOptions: ICommonScreenOptions = {
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerTransparent: Platform.OS === "ios",
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerBlurEffect: "none",
|
headerBlurEffect: "none",
|
||||||
|
|||||||
115
components/tv/TVActorCard.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVActorCardProps {
|
||||||
|
person: {
|
||||||
|
Id?: string | null;
|
||||||
|
Name?: string | null;
|
||||||
|
Role?: string | null;
|
||||||
|
};
|
||||||
|
apiBasePath?: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
||||||
|
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
|
const imageUrl = person.Id
|
||||||
|
? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
width: 120,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
|
shadowRadius: focused ? 16 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: focused ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='person'
|
||||||
|
size={40}
|
||||||
|
color='rgba(255,255,255,0.4)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{person.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{person.Role && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: focused
|
||||||
|
? "rgba(255,255,255,0.8)"
|
||||||
|
: "rgba(255,255,255,0.5)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{person.Role}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
56
components/tv/TVBackdrop.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
|
||||||
|
export interface TVBackdropProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVBackdrop: React.FC<TVBackdropProps> = React.memo(({ item }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
variant='Backdrop'
|
||||||
|
item={item}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
104
components/tv/TVButton.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View, type ViewStyle } from "react-native";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVButtonProps {
|
||||||
|
onPress: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: "primary" | "secondary" | "glass";
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
style?: ViewStyle;
|
||||||
|
scaleAmount?: number;
|
||||||
|
square?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getButtonStyles = (
|
||||||
|
variant: "primary" | "secondary" | "glass",
|
||||||
|
focused: boolean,
|
||||||
|
) => {
|
||||||
|
switch (variant) {
|
||||||
|
case "glass":
|
||||||
|
return {
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(255, 255, 255, 0.25)"
|
||||||
|
: "rgba(255, 255, 255, 0.1)",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: focused
|
||||||
|
? "rgba(255, 255, 255, 0.4)"
|
||||||
|
: "rgba(255, 255, 255, 0.15)",
|
||||||
|
};
|
||||||
|
case "secondary":
|
||||||
|
return {
|
||||||
|
backgroundColor: focused ? "#7c3aed" : "rgba(124, 58, 237, 0.8)",
|
||||||
|
shadowColor: "#a855f7",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "transparent",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
backgroundColor: focused ? "#ffffff" : "rgba(255, 255, 255, 0.9)",
|
||||||
|
shadowColor: "#fff",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "transparent",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TVButton: React.FC<TVButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
style,
|
||||||
|
scaleAmount = 1.05,
|
||||||
|
square = false,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount });
|
||||||
|
|
||||||
|
const buttonStyles = getButtonStyles(variant, focused);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
shadowColor: buttonStyles.shadowColor,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 15 : 0,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: buttonStyles.backgroundColor,
|
||||||
|
borderWidth: buttonStyles.borderWidth,
|
||||||
|
borderColor: buttonStyles.borderColor,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: square ? 18 : 32,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: square ? undefined : 180,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
60
components/tv/TVCancelButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVCancelButtonProps {
|
||||||
|
onPress: () => void;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
label = "Cancel",
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<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='close'
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
75
components/tv/TVCastCrewText.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
export interface TVCastCrewTextProps {
|
||||||
|
director?: BaseItemPerson | null;
|
||||||
|
cast?: BaseItemPerson[];
|
||||||
|
/** Hide the cast section (e.g., when visual cast section is shown) */
|
||||||
|
hideCast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||||
|
({ director, cast, hideCast = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!director && (!cast || cast.length === 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.cast_and_crew")}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", gap: 40 }}>
|
||||||
|
{director && (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.director")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
||||||
|
{director.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{!hideCast && cast && cast.length > 0 && (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.cast")}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
||||||
|
{cast.map((c) => c.Name).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
78
components/tv/TVCastSection.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { BaseItemPerson } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, TVFocusGuideView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVActorCard } from "./TVActorCard";
|
||||||
|
|
||||||
|
export interface TVCastSectionProps {
|
||||||
|
cast: BaseItemPerson[];
|
||||||
|
apiBasePath?: string;
|
||||||
|
onActorPress: (personId: string) => void;
|
||||||
|
/** Setter function for the first actor card ref (for focus guide) */
|
||||||
|
firstActorRefSetter?: (ref: View | null) => void;
|
||||||
|
/** Ref to focus guide destination for upward navigation */
|
||||||
|
upwardFocusDestination?: View | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
|
||||||
|
({
|
||||||
|
cast,
|
||||||
|
apiBasePath,
|
||||||
|
onActorPress,
|
||||||
|
firstActorRefSetter,
|
||||||
|
upwardFocusDestination,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (cast.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.cast")}
|
||||||
|
</Text>
|
||||||
|
{/* Focus guide to direct upward navigation from cast back to options */}
|
||||||
|
{upwardFocusDestination && (
|
||||||
|
<TVFocusGuideView
|
||||||
|
destinations={[upwardFocusDestination]}
|
||||||
|
style={{ height: 1, width: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 80,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cast.map((person, index) => (
|
||||||
|
<TVActorCard
|
||||||
|
key={person.Id || index}
|
||||||
|
ref={index === 0 ? firstActorRefSetter : undefined}
|
||||||
|
person={person}
|
||||||
|
apiBasePath={apiBasePath}
|
||||||
|
onPress={() => {
|
||||||
|
if (person.Id) {
|
||||||
|
onActorPress(person.Id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
72
components/tv/TVControlButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVControlButtonProps {
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
onPress: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
onPressOut?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
size?: number;
|
||||||
|
delayLongPress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVControlButton: FC<TVControlButtonProps> = ({
|
||||||
|
icon,
|
||||||
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
onPressOut,
|
||||||
|
disabled,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
size = 32,
|
||||||
|
delayLongPress = 300,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
delayLongPress={delayLongPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
>
|
||||||
|
<RNAnimated.View
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(255,255,255,0.3)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
borderColor: focused
|
||||||
|
? "rgba(255,255,255,0.8)"
|
||||||
|
: "rgba(255,255,255,0.2)",
|
||||||
|
opacity: disabled ? 0.3 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={size} color='#fff' />
|
||||||
|
</RNAnimated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
borderWidth: 2,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
73
components/tv/TVFocusablePoster.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { Animated, Easing, Pressable, type ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export interface TVFocusablePosterProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
glowColor?: "white" | "purple";
|
||||||
|
scaleAmount?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
glowColor = "white",
|
||||||
|
scaleAmount = 1.05,
|
||||||
|
style,
|
||||||
|
onFocus: onFocusProp,
|
||||||
|
onBlur: onBlurProp,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
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();
|
||||||
|
|
||||||
|
const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(scaleAmount);
|
||||||
|
onFocusProp?.();
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
onBlurProp?.();
|
||||||
|
}}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor,
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
components/tv/TVLanguageCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVLanguageCardProps {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
selected: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
||||||
|
({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.languageCard,
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.languageCardText,
|
||||||
|
{ color: focused ? "#000" : "#fff" },
|
||||||
|
(focused || selected) && { fontWeight: "600" },
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.languageCardCode,
|
||||||
|
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{code.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
{selected && !focused && (
|
||||||
|
<View style={styles.checkmark}>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={16}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
languageCard: {
|
||||||
|
width: 120,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
languageCardText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
languageCardCode: {
|
||||||
|
fontSize: 11,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
checkmark: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
43
components/tv/TVMetadataBadges.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
export interface TVMetadataBadgesProps {
|
||||||
|
year?: number | null;
|
||||||
|
duration?: string | null;
|
||||||
|
officialRating?: string | null;
|
||||||
|
communityRating?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
||||||
|
({ year, duration, officialRating, communityRating }) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year != null && (
|
||||||
|
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{year}</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<Text style={{ color: "#9CA3AF", fontSize: 18 }}>{duration}</Text>
|
||||||
|
)}
|
||||||
|
{officialRating && <Badge text={officialRating} variant='gray' />}
|
||||||
|
{communityRating != null && (
|
||||||
|
<Badge
|
||||||
|
text={communityRating.toFixed(1)}
|
||||||
|
variant='gray'
|
||||||
|
iconLeft={<Ionicons name='star' size={16} color='gold' />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
160
components/tv/TVNextEpisodeCountdown.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import type { Api } from "@jellyfin/sdk";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { type FC, useEffect, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Image, StyleSheet, View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
cancelAnimation,
|
||||||
|
Easing,
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
|
export interface TVNextEpisodeCountdownProps {
|
||||||
|
nextItem: BaseItemDto;
|
||||||
|
api: Api | null;
|
||||||
|
show: boolean;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onFinish: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||||
|
nextItem,
|
||||||
|
api,
|
||||||
|
show,
|
||||||
|
isPlaying,
|
||||||
|
onFinish,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const progress = useSharedValue(0);
|
||||||
|
const onFinishRef = useRef(onFinish);
|
||||||
|
|
||||||
|
onFinishRef.current = onFinish;
|
||||||
|
|
||||||
|
const imageUrl = getPrimaryImageUrl({
|
||||||
|
api,
|
||||||
|
item: nextItem,
|
||||||
|
width: 360,
|
||||||
|
quality: 80,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show && isPlaying) {
|
||||||
|
progress.value = 0;
|
||||||
|
progress.value = withTiming(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
duration: 8000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
},
|
||||||
|
(finished) => {
|
||||||
|
if (finished && onFinishRef.current) {
|
||||||
|
runOnJS(onFinishRef.current)();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cancelAnimation(progress);
|
||||||
|
progress.value = 0;
|
||||||
|
}
|
||||||
|
}, [show, isPlaying, progress]);
|
||||||
|
|
||||||
|
const progressStyle = useAnimatedStyle(() => ({
|
||||||
|
width: `${progress.value * 100}%`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} pointerEvents='none'>
|
||||||
|
<BlurView intensity={80} tint='dark' style={styles.blur}>
|
||||||
|
<View style={styles.innerContainer}>
|
||||||
|
{imageUrl && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.thumbnail}
|
||||||
|
resizeMode='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.label}>{t("player.next_episode")}</Text>
|
||||||
|
|
||||||
|
<Text style={styles.seriesName} numberOfLines={1}>
|
||||||
|
{nextItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={styles.episodeInfo} numberOfLines={1}>
|
||||||
|
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
|
||||||
|
{nextItem.Name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.progressContainer}>
|
||||||
|
<Animated.View style={[styles.progressBar, progressStyle]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 180,
|
||||||
|
right: 80,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
blur: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
innerContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
width: 180,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 280,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
seriesName: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
episodeInfo: {
|
||||||
|
fontSize: 20,
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
progressContainer: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
71
components/tv/TVOptionButton.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVOptionButtonProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||||
|
({ label, value, onPress, hasTVPreferredFocus }, ref) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.4 : 0,
|
||||||
|
shadowRadius: focused ? 12 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: focused ? "#444" : "#bbb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
102
components/tv/TVOptionCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
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 "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVOptionCardProps {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
selected: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
selected,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
onPress,
|
||||||
|
width = 160,
|
||||||
|
height = 75,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused || selected ? "600" : "400",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{sublabel && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{sublabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{selected && !focused && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={16}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
199
components/tv/TVOptionSelector.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVCancelButton } from "./TVCancelButton";
|
||||||
|
import { TVOptionCard } from "./TVOptionCard";
|
||||||
|
|
||||||
|
export type TVOptionItem<T> = {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
value: T;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TVOptionSelectorProps<T> {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
options: TVOptionItem<T>[];
|
||||||
|
onSelect: (value: T) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
cancelLabel?: string;
|
||||||
|
cardWidth?: number;
|
||||||
|
cardHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVOptionSelector = <T,>({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
cardWidth = 160,
|
||||||
|
cardHeight = 75,
|
||||||
|
}: TVOptionSelectorProps<T>) => {
|
||||||
|
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(() => {
|
||||||
|
const idx = options.findIndex((o) => o.selected);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
const timer = setTimeout(() => setIsReady(true), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
setIsReady(false);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && firstCardRef.current) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
(firstCardRef.current as any)?.requestTVFocus?.();
|
||||||
|
}, 50);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isReady]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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={() => {
|
||||||
|
onSelect(option.value);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
width={cardWidth}
|
||||||
|
height={cardHeight}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && (
|
||||||
|
<View style={styles.cancelButtonContainer}>
|
||||||
|
<TVCancelButton onPress={onClose} label={cancelLabel} />
|
||||||
|
</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",
|
||||||
|
},
|
||||||
|
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: 10,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
cancelButtonContainer: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
});
|
||||||
49
components/tv/TVProgressBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
export interface TVProgressBarProps {
|
||||||
|
/** Progress value between 0 and 1 */
|
||||||
|
progress: number;
|
||||||
|
/** Background color of the track */
|
||||||
|
trackColor?: string;
|
||||||
|
/** Color of the progress fill */
|
||||||
|
fillColor?: string;
|
||||||
|
/** Maximum width of the progress bar */
|
||||||
|
maxWidth?: number;
|
||||||
|
/** Height of the progress bar */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVProgressBar: React.FC<TVProgressBarProps> = React.memo(
|
||||||
|
({
|
||||||
|
progress,
|
||||||
|
trackColor = "rgba(255,255,255,0.2)",
|
||||||
|
fillColor = "#a855f7",
|
||||||
|
maxWidth = 400,
|
||||||
|
height = 4,
|
||||||
|
}) => {
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, progress));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ maxWidth, marginBottom: 24 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
backgroundColor: trackColor,
|
||||||
|
borderRadius: height / 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${clampedProgress * 100}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: fillColor,
|
||||||
|
borderRadius: height / 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
70
components/tv/TVRefreshButton.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Animated, Easing } from "react-native";
|
||||||
|
import { TVButton } from "./TVButton";
|
||||||
|
|
||||||
|
export interface TVRefreshButtonProps {
|
||||||
|
itemId: string | undefined;
|
||||||
|
queryClient?: QueryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVRefreshButton: React.FC<TVRefreshButtonProps> = ({
|
||||||
|
itemId,
|
||||||
|
queryClient: externalQueryClient,
|
||||||
|
}) => {
|
||||||
|
const defaultQueryClient = useQueryClient();
|
||||||
|
const queryClient = externalQueryClient ?? defaultQueryClient;
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const spinValue = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRefreshing) {
|
||||||
|
spinValue.setValue(0);
|
||||||
|
Animated.loop(
|
||||||
|
Animated.timing(spinValue, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
).start();
|
||||||
|
} else {
|
||||||
|
spinValue.stopAnimation();
|
||||||
|
spinValue.setValue(0);
|
||||||
|
}
|
||||||
|
}, [isRefreshing, spinValue]);
|
||||||
|
|
||||||
|
const spin = spinValue.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ["0deg", "360deg"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
if (!itemId || isRefreshing) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["item", itemId] }),
|
||||||
|
minSpinTime,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [itemId, queryClient, isRefreshing]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TVButton
|
||||||
|
onPress={handleRefresh}
|
||||||
|
variant='glass'
|
||||||
|
square
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
||||||
|
<Ionicons name='refresh' size={28} color='#FFFFFF' />
|
||||||
|
</Animated.View>
|
||||||
|
</TVButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
69
components/tv/TVSeriesNavigation.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
||||||
|
|
||||||
|
export interface TVSeriesNavigationProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
seriesImageUrl?: string | null;
|
||||||
|
seasonImageUrl?: string | null;
|
||||||
|
onSeriesPress: () => void;
|
||||||
|
onSeasonPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
||||||
|
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Only show for episodes with a series
|
||||||
|
if (item.Type !== "Episode" || !item.SeriesId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.from_this_series") || "From this Series"}
|
||||||
|
</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 80,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Series card */}
|
||||||
|
<TVSeriesSeasonCard
|
||||||
|
title={item.SeriesName || "Series"}
|
||||||
|
subtitle={t("item_card.view_series") || "View Series"}
|
||||||
|
imageUrl={seriesImageUrl ?? null}
|
||||||
|
onPress={onSeriesPress}
|
||||||
|
hasTVPreferredFocus={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Season card */}
|
||||||
|
{(item.SeasonId || item.ParentId) && (
|
||||||
|
<TVSeriesSeasonCard
|
||||||
|
title={item.SeasonName || `Season ${item.ParentIndexNumber}`}
|
||||||
|
subtitle={t("item_card.view_season") || "View Season"}
|
||||||
|
imageUrl={seasonImageUrl ?? null}
|
||||||
|
onPress={onSeasonPress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
106
components/tv/TVSeriesSeasonCard.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVSeriesSeasonCardProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
imageUrl,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
width: 140,
|
||||||
|
shadowColor: "#fff",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
|
shadowRadius: focused ? 16 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 140,
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: focused ? 3 : 0,
|
||||||
|
borderColor: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film' size={40} color='rgba(255,255,255,0.4)' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 2,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{subtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: focused
|
||||||
|
? "rgba(255,255,255,0.8)"
|
||||||
|
: "rgba(255,255,255,0.5)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
267
components/tv/TVSubtitleResultCard.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVSubtitleResultCardProps {
|
||||||
|
result: SubtitleSearchResult;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
isDownloading?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSubtitleResultCard = React.forwardRef<
|
||||||
|
View,
|
||||||
|
TVSubtitleResultCardProps
|
||||||
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.resultCard,
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
|
||||||
|
borderColor: focused
|
||||||
|
? "rgba(255,255,255,0.8)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Provider/Source badge */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.providerBadge,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(0,0,0,0.1)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.providerText,
|
||||||
|
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{result.providerName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<Text
|
||||||
|
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{result.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Meta info row */}
|
||||||
|
<View style={styles.resultMeta}>
|
||||||
|
{/* Format */}
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.resultMetaText,
|
||||||
|
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{result.format?.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Rating if available */}
|
||||||
|
{result.communityRating !== undefined &&
|
||||||
|
result.communityRating > 0 && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name='star'
|
||||||
|
size={12}
|
||||||
|
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.resultMetaText,
|
||||||
|
{
|
||||||
|
color: focused
|
||||||
|
? "rgba(0,0,0,0.6)"
|
||||||
|
: "rgba(255,255,255,0.5)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{result.communityRating.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download count if available */}
|
||||||
|
{result.downloadCount !== undefined && result.downloadCount > 0 && (
|
||||||
|
<View style={styles.downloadCountContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name='download-outline'
|
||||||
|
size={12}
|
||||||
|
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.resultMetaText,
|
||||||
|
{
|
||||||
|
color: focused
|
||||||
|
? "rgba(0,0,0,0.6)"
|
||||||
|
: "rgba(255,255,255,0.5)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{result.downloadCount.toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<View style={styles.flagsContainer}>
|
||||||
|
{result.isHashMatch && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.flag,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(0,150,0,0.2)"
|
||||||
|
: "rgba(0,200,0,0.2)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.flagText}>Hash Match</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{result.hearingImpaired && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.flag,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(0,0,0,0.1)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='ear-outline'
|
||||||
|
size={12}
|
||||||
|
color={focused ? "#000" : "#fff"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{result.aiTranslated && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.flag,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "rgba(0,0,150,0.2)"
|
||||||
|
: "rgba(100,100,255,0.2)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.flagText}>AI</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Loading indicator when downloading */}
|
||||||
|
{isDownloading && (
|
||||||
|
<View style={styles.downloadingOverlay}>
|
||||||
|
<ActivityIndicator size='small' color='#fff' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
resultCard: {
|
||||||
|
width: 220,
|
||||||
|
minHeight: 120,
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
providerBadge: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
providerText: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "600",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
resultName: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "500",
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
resultMeta: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
resultMetaText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
downloadCountContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 3,
|
||||||
|
},
|
||||||
|
flagsContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 6,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
flag: {
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
flagText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
downloadingOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
68
components/tv/TVTabButton.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVTabButtonProps {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
switchOnFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
switchOnFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({
|
||||||
|
scaleAmount: 1.05,
|
||||||
|
duration: 120,
|
||||||
|
onFocus: switchOnFocus ? onSelect : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelect}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: active
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "transparent",
|
||||||
|
borderBottomColor: active ? "#fff" : "transparent",
|
||||||
|
borderBottomWidth: 2,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: focused ? "#000" : "#fff",
|
||||||
|
fontWeight: focused || active ? "600" : "400",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
77
components/tv/TVTechnicalDetails.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
export interface TVTechnicalDetailsProps {
|
||||||
|
mediaStreams: MediaStream[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
||||||
|
({ mediaStreams }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const videoStream = mediaStreams.find((s) => s.Type === "Video");
|
||||||
|
const audioStream = mediaStreams.find((s) => s.Type === "Audio");
|
||||||
|
|
||||||
|
if (!videoStream && !audioStream) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 32 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.technical_details")}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", gap: 40 }}>
|
||||||
|
{videoStream && (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Video
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
||||||
|
{videoStream.DisplayTitle ||
|
||||||
|
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{audioStream && (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: "#6B7280",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Audio
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 18, color: "#FFFFFF" }}>
|
||||||
|
{audioStream.DisplayTitle ||
|
||||||
|
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
101
components/tv/TVTrackCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVTrackCardProps {
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
selected: boolean;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
||||||
|
({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
ref={ref}
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.trackCard,
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: selected
|
||||||
|
? "rgba(255,255,255,0.2)"
|
||||||
|
: "rgba(255,255,255,0.08)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.trackCardText,
|
||||||
|
{ color: focused ? "#000" : "#fff" },
|
||||||
|
(focused || selected) && { fontWeight: "600" },
|
||||||
|
]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{sublabel && (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.trackCardSublabel,
|
||||||
|
{
|
||||||
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{sublabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{selected && !focused && (
|
||||||
|
<View style={styles.checkmark}>
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark'
|
||||||
|
size={16}
|
||||||
|
color='rgba(255,255,255,0.8)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
trackCard: {
|
||||||
|
width: 180,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 14,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
},
|
||||||
|
trackCardText: {
|
||||||
|
fontSize: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
trackCardSublabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
checkmark: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
61
components/tv/hooks/useTVFocusAnimation.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { Animated, Easing } from "react-native";
|
||||||
|
|
||||||
|
export interface UseTVFocusAnimationOptions {
|
||||||
|
scaleAmount?: number;
|
||||||
|
duration?: number;
|
||||||
|
onFocus?: () => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTVFocusAnimationReturn {
|
||||||
|
focused: boolean;
|
||||||
|
scale: Animated.Value;
|
||||||
|
handleFocus: () => void;
|
||||||
|
handleBlur: () => void;
|
||||||
|
animatedStyle: { transform: { scale: Animated.Value }[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTVFocusAnimation = ({
|
||||||
|
scaleAmount = 1.05,
|
||||||
|
duration = 150,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(value: number) => {
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
},
|
||||||
|
[scale, duration],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(scaleAmount);
|
||||||
|
onFocus?.();
|
||||||
|
}, [animateTo, scaleAmount, onFocus]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
onBlur?.();
|
||||||
|
}, [animateTo, onBlur]);
|
||||||
|
|
||||||
|
const animatedStyle = { transform: [{ scale }] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
focused,
|
||||||
|
scale,
|
||||||
|
handleFocus,
|
||||||
|
handleBlur,
|
||||||
|
animatedStyle,
|
||||||
|
};
|
||||||
|
};
|
||||||
56
components/tv/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Hooks
|
||||||
|
export type {
|
||||||
|
UseTVFocusAnimationOptions,
|
||||||
|
UseTVFocusAnimationReturn,
|
||||||
|
} from "./hooks/useTVFocusAnimation";
|
||||||
|
export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
// Settings components (re-export from settings/)
|
||||||
|
export * from "./settings";
|
||||||
|
// Item content components
|
||||||
|
export type { TVActorCardProps } from "./TVActorCard";
|
||||||
|
export { TVActorCard } from "./TVActorCard";
|
||||||
|
export type { TVBackdropProps } from "./TVBackdrop";
|
||||||
|
export { TVBackdrop } from "./TVBackdrop";
|
||||||
|
// Core components
|
||||||
|
export type { TVButtonProps } from "./TVButton";
|
||||||
|
export { TVButton } from "./TVButton";
|
||||||
|
export type { TVCancelButtonProps } from "./TVCancelButton";
|
||||||
|
export { TVCancelButton } from "./TVCancelButton";
|
||||||
|
export type { TVCastCrewTextProps } from "./TVCastCrewText";
|
||||||
|
export { TVCastCrewText } from "./TVCastCrewText";
|
||||||
|
export type { TVCastSectionProps } from "./TVCastSection";
|
||||||
|
export { TVCastSection } from "./TVCastSection";
|
||||||
|
// Player control components
|
||||||
|
export type { TVControlButtonProps } from "./TVControlButton";
|
||||||
|
export { TVControlButton } from "./TVControlButton";
|
||||||
|
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
|
||||||
|
export { TVFocusablePoster } from "./TVFocusablePoster";
|
||||||
|
export type { TVLanguageCardProps } from "./TVLanguageCard";
|
||||||
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
|
export { TVMetadataBadges } from "./TVMetadataBadges";
|
||||||
|
export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
|
||||||
|
export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
|
||||||
|
export type { TVOptionButtonProps } from "./TVOptionButton";
|
||||||
|
export { TVOptionButton } from "./TVOptionButton";
|
||||||
|
export type { TVOptionCardProps } from "./TVOptionCard";
|
||||||
|
export { TVOptionCard } from "./TVOptionCard";
|
||||||
|
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
|
||||||
|
export { TVOptionSelector } from "./TVOptionSelector";
|
||||||
|
export type { TVProgressBarProps } from "./TVProgressBar";
|
||||||
|
export { TVProgressBar } from "./TVProgressBar";
|
||||||
|
export type { TVRefreshButtonProps } from "./TVRefreshButton";
|
||||||
|
export { TVRefreshButton } from "./TVRefreshButton";
|
||||||
|
export type { TVSeriesNavigationProps } from "./TVSeriesNavigation";
|
||||||
|
export { TVSeriesNavigation } from "./TVSeriesNavigation";
|
||||||
|
export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
|
||||||
|
export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
||||||
|
export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
|
||||||
|
export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
|
||||||
|
export type { TVTabButtonProps } from "./TVTabButton";
|
||||||
|
export { TVTabButton } from "./TVTabButton";
|
||||||
|
export type { TVTechnicalDetailsProps } from "./TVTechnicalDetails";
|
||||||
|
export { TVTechnicalDetails } from "./TVTechnicalDetails";
|
||||||
|
// Subtitle sheet components
|
||||||
|
export type { TVTrackCardProps } from "./TVTrackCard";
|
||||||
|
export { TVTrackCard } from "./TVTrackCard";
|
||||||
62
components/tv/settings/TVLogoutButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVLogoutButtonProps {
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
animatedStyle,
|
||||||
|
{
|
||||||
|
shadowColor: "#ef4444",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused ? "#ef4444" : "rgba(239, 68, 68, 0.8)",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("home.settings.log_out_button")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
components/tv/settings/TVSectionHeader.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
export interface TVSectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginTop: 32,
|
||||||
|
marginBottom: 16,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||