feat(tv): add option selector for playback settings

This commit is contained in:
Fredrik Burmester
2026-01-16 13:00:26 +01:00
parent db89295d9b
commit be32d933bb
7 changed files with 1181 additions and 110 deletions

View File

@@ -134,3 +134,89 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- 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`

View File

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

View File

@@ -3,10 +3,17 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
@@ -18,7 +25,7 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Badge } from "@/components/Badge";
import { type Bitrate } from "@/components/BitrateSelector";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
@@ -133,6 +140,406 @@ const _InfoRow: React.FC<{ label: string; value: string }> = ({
</View>
);
// Option item for the TV selector modal
type TVOptionItem<T> = {
label: string;
value: T;
selected: boolean;
};
// TV Option Selector (Modal style - saved as backup)
const _TVOptionSelectorModal = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
// Find the initially selected index
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
if (!visible) return null;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
}}
>
<View
style={{
backgroundColor: "#111",
borderRadius: 16,
paddingVertical: 24,
paddingHorizontal: 32,
minWidth: 420,
maxWidth: SCREEN_WIDTH * 0.4,
maxHeight: SCREEN_HEIGHT * 0.7,
overflow: "visible",
}}
>
{/* Header */}
<Text
style={{
fontSize: 24,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
paddingHorizontal: 8,
}}
>
{title}
</Text>
{/* Options list */}
<ScrollView
showsVerticalScrollIndicator={false}
style={{ maxHeight: SCREEN_HEIGHT * 0.5, overflow: "visible" }}
contentContainerStyle={{ paddingVertical: 4, paddingHorizontal: 4 }}
>
{options.map((option, index) => (
<_TVOptionRowModal
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</View>
);
};
// Individual option row in the modal selector (backup)
const _TVOptionRowModal: 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: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
style={{ marginBottom: 2 }}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
backgroundColor: focused ? "#2a2a2a" : "transparent",
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 16,
}}
>
<View style={{ width: 28, marginRight: 12 }}>
{selected && <Ionicons name='checkmark' size={22} color='#a855f7' />}
</View>
<Text
style={{
fontSize: 18,
color: focused || selected ? "#FFFFFF" : "#888",
fontWeight: selected ? "600" : "400",
flex: 1,
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style)
const TVOptionSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
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,
}}
>
{title}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
// Option card for horizontal selector (Apple TV style)
const TVOptionCard: 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>
);
};
// Button to open option selector
const TVOptionButton: React.FC<{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ label, value, onPress, hasTVPreferredFocus }) => {
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}
>
<Animated.View
style={{
transform: [{ scale }],
}}
>
<View
style={{
backgroundColor: focused ? "#2a2a2a" : "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
borderWidth: 2,
borderColor: focused ? "#fff" : "transparent",
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Text
style={{
fontSize: 14,
color: "#888",
}}
>
{label}
</Text>
<Text
style={{
fontSize: 14,
color: "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
@@ -194,6 +601,152 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
router.push(`/player/direct-player?${queryParams.toString()}`);
};
// Modal state for option selectors
type ModalType = "audio" | "subtitle" | "mediaSource" | "quality" | null;
const [openModal, setOpenModal] = useState<ModalType>(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 = useMemo(() => {
return audioTracks.map((track) => ({
label:
track.DisplayTitle ||
`${track.Language || "Unknown"} (${track.Codec})`,
value: track.Index!,
selected: track.Index === selectedOptions?.audioIndex,
}));
}, [audioTracks, selectedOptions?.audioIndex]);
// Subtitle options for selector (with "None" option)
const subtitleOptions = useMemo(() => {
const noneOption = {
label: t("subtitles.none") || "None",
value: -1,
selected: selectedOptions?.subtitleIndex === -1,
};
const trackOptions = subtitleTracks.map((track) => ({
label:
track.DisplayTitle ||
`${track.Language || "Unknown"} (${track.Codec})`,
value: track.Index!,
selected: track.Index === selectedOptions?.subtitleIndex,
}));
return [noneOption, ...trackOptions];
}, [subtitleTracks, selectedOptions?.subtitleIndex, t]);
// Media source options for selector
const mediaSourceOptions = 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 = 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) => {
// When media source changes, reset audio/subtitle to defaults
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));
}, []);
// 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("subtitles.none") || "None";
const track = subtitleTracks.find(
(t) => t.Index === selectedOptions?.subtitleIndex,
);
return track?.DisplayTitle || track?.Language || t("item_card.subtitles");
}, [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
@@ -450,6 +1003,51 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</TVFocusableButton>
</View>
{/* Playback options row */}
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
marginBottom: 24,
}}
>
{/* Quality selector */}
<TVOptionButton
label={t("item_card.quality")}
value={selectedQualityLabel}
onPress={() => setOpenModal("quality")}
/>
{/* Media source selector (only if multiple sources) */}
{mediaSources.length > 1 && (
<TVOptionButton
label={t("item_card.video")}
value={selectedMediaSourceLabel}
onPress={() => setOpenModal("mediaSource")}
/>
)}
{/* Audio selector */}
{audioTracks.length > 0 && (
<TVOptionButton
label={t("item_card.audio")}
value={selectedAudioLabel}
onPress={() => setOpenModal("audio")}
/>
)}
{/* Subtitle selector */}
{(subtitleTracks.length > 0 ||
selectedOptions?.subtitleIndex !== undefined) && (
<TVOptionButton
label={t("item_card.subtitles")}
value={selectedSubtitleLabel}
onPress={() => setOpenModal("subtitle")}
/>
)}
</View>
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<View style={{ maxWidth: 400, marginBottom: 24 }}>
@@ -605,6 +1203,39 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
</View>
</ScrollView>
{/* Option selector modals */}
<TVOptionSelector
visible={openModal === "quality"}
title={t("item_card.quality")}
options={qualityOptions}
onSelect={handleQualityChange}
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
visible={openModal === "mediaSource"}
title={t("item_card.video")}
options={mediaSourceOptions}
onSelect={handleMediaSourceChange}
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
visible={openModal === "audio"}
title={t("item_card.audio")}
options={audioOptions}
onSelect={handleAudioChange}
onClose={() => setOpenModal(null)}
/>
<TVOptionSelector
visible={openModal === "subtitle"}
title={t("item_card.subtitles")}
options={subtitleOptions}
onSelect={handleSubtitleChange}
onClose={() => setOpenModal(null)}
/>
</View>
);
},

View 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' />
)
}
/>
);
};

View File

@@ -0,0 +1,165 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, 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 { Loader } from "@/components/Loader";
import {
TV_LIBRARY_CARD_WIDTH,
TVLibraryCard,
} from "@/components/library/TVLibraryCard";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
const HORIZONTAL_PADDING = 60;
const ITEM_GAP = 24;
const SCALE_PADDING = 20;
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 flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
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,
enabled: !!api && !!user?.Id,
});
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
// Scroll back to start when section loses focus
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 handleItemPress = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(libraries)");
router.push(navigation as any);
},
[router],
);
const getItemLayout = useCallback(
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
length: TV_LIBRARY_CARD_WIDTH + ITEM_GAP,
offset: (TV_LIBRARY_CARD_WIDTH + ITEM_GAP) * index,
index,
}),
[],
);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
const isFirstItem = index === 0;
return (
<View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
>
<TVLibraryCard library={item} />
</TVFocusablePoster>
</View>
);
},
[handleItemPress, handleItemFocus, handleItemBlur],
);
if (isLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
if (!libraries || libraries.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,
justifyContent: "center",
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<FlatList
ref={flatListRef}
horizontal
data={libraries}
keyExtractor={(item) => item.Id || ""}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
getItemLayout={getItemLayout}
style={{ overflow: "visible", flexGrow: 0 }}
contentContainerStyle={{
paddingVertical: SCALE_PADDING,
paddingHorizontal: SCALE_PADDING,
}}
/>
</View>
);
};

View 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>
);
};

View File

@@ -627,7 +627,10 @@
"media_options": "Media Options",
"quality": "Quality",
"audio": "Audio",
"subtitles": "Subtitle",
"subtitles": {
"label": "Subtitle",
"none": "None"
},
"show_more": "Show More",
"show_less": "Show Less",
"left": "left",
@@ -721,7 +724,8 @@
"search": "Search",
"library": "Library",
"custom_links": "Custom Links",
"favorites": "Favorites"
"favorites": "Favorites",
"settings": "Settings"
},
"music": {
"title": "Music",