From be32d933bb625e1de67026455b91925450f3d302 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 13:00:26 +0100 Subject: [PATCH] feat(tv): add option selector for playback settings --- CLAUDE.md | 86 ++++ app/(auth)/(tabs)/(libraries)/index.tsx | 114 +---- components/ItemContent.tv.tsx | 635 +++++++++++++++++++++++- components/library/Libraries.tsx | 109 ++++ components/library/TVLibraries.tsx | 165 ++++++ components/library/TVLibraryCard.tsx | 174 +++++++ translations/en.json | 8 +- 7 files changed, 1181 insertions(+), 110 deletions(-) create mode 100644 components/library/Libraries.tsx create mode 100644 components/library/TVLibraries.tsx create mode 100644 components/library/TVLibraryCard.tsx diff --git a/CLAUDE.md b/CLAUDE.md index cc3b0a53..d60543c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ; + } + return ; +}; + +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 + + + {/* Content */} + + +``` + +2. **Horizontal ScrollView with focusable cards**: +```typescript + + {options.map((option, index) => ( + { onSelect(option.value); onClose(); }} + // ... + /> + ))} + +``` + +3. **Focus handling on cards** - Use `Pressable` with `onFocus`/`onBlur` and `hasTVPreferredFocus`: +```typescript + { setFocused(true); animateTo(1.05); }} + onBlur={() => { setFocused(false); animateTo(1); }} + hasTVPreferredFocus={hasTVPreferredFocus} +> + + {label} + + +``` + +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` diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx index 37b89bbf..481503cf 100644 --- a/app/(auth)/(tabs)/(libraries)/index.tsx +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -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 ; + } - 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 ( - - - - ); - - if (!libraries) - return ( - - - {t("library.no_libraries_found")} - - - ); - - return ( - } - keyExtractor={(item) => item.Id || ""} - ItemSeparatorComponent={() => - settings?.libraryOptions?.display === "row" ? ( - - ) : ( - - ) - } - /> - ); + return ; } diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 48647ce6..392bec19 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -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 }> = ({ ); +// Option item for the TV selector modal +type TVOptionItem = { + label: string; + value: T; + selected: boolean; +}; + +// TV Option Selector (Modal style - saved as backup) +const _TVOptionSelectorModal = ({ + visible, + title, + options, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + options: TVOptionItem[]; + 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 ( + + + {/* Header */} + + {title} + + + {/* Options list */} + + {options.map((option, index) => ( + <_TVOptionRowModal + key={index} + label={option.label} + selected={option.selected} + hasTVPreferredFocus={index === initialSelectedIndex} + onPress={() => { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + ); +}; + +// 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 ( + { + setFocused(true); + animateTo(1.02); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + style={{ marginBottom: 2 }} + > + + + {selected && } + + + {label} + + + + ); +}; + +// TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style) +const TVOptionSelector = ({ + visible, + title, + options, + onSelect, + onClose, +}: { + visible: boolean; + title: string; + options: TVOptionItem[]; + 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 ( + + + + {/* Title */} + + {title} + + + {/* Horizontal options */} + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + /> + ))} + + + + + ); +}; + +// 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 ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {label} + + {selected && !focused && ( + + + + )} + + + ); +}; + +// 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 ( + { + setFocused(true); + animateTo(1.04); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + {label} + + + {value} + + + + + ); +}; + // Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports) export const ItemContentTV: React.FC = React.memo( ({ item, itemWithSources }) => { @@ -194,6 +601,152 @@ export const ItemContentTV: React.FC = 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(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 = React.memo( + {/* Playback options row */} + + {/* Quality selector */} + setOpenModal("quality")} + /> + + {/* Media source selector (only if multiple sources) */} + {mediaSources.length > 1 && ( + setOpenModal("mediaSource")} + /> + )} + + {/* Audio selector */} + {audioTracks.length > 0 && ( + setOpenModal("audio")} + /> + )} + + {/* Subtitle selector */} + {(subtitleTracks.length > 0 || + selectedOptions?.subtitleIndex !== undefined) && ( + setOpenModal("subtitle")} + /> + )} + + {/* Progress bar (if partially watched) */} {hasProgress && item.RunTimeTicks != null && ( @@ -605,6 +1203,39 @@ export const ItemContentTV: React.FC = React.memo( )} + + {/* Option selector modals */} + setOpenModal(null)} + /> + + setOpenModal(null)} + /> + + setOpenModal(null)} + /> + + setOpenModal(null)} + /> ); }, diff --git a/components/library/Libraries.tsx b/components/library/Libraries.tsx new file mode 100644 index 00000000..8be2436c --- /dev/null +++ b/components/library/Libraries.tsx @@ -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 ( + + + + ); + + if (!libraries) + return ( + + + {t("library.no_libraries_found")} + + + ); + + return ( + } + keyExtractor={(item) => item.Id || ""} + ItemSeparatorComponent={() => + settings?.libraryOptions?.display === "row" ? ( + + ) : ( + + ) + } + /> + ); +}; diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx new file mode 100644 index 00000000..55e96db4 --- /dev/null +++ b/components/library/TVLibraries.tsx @@ -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>(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 | 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 ( + + handleItemPress(item)} + hasTVPreferredFocus={isFirstItem} + onFocus={handleItemFocus} + onBlur={handleItemBlur} + > + + + + ); + }, + [handleItemPress, handleItemFocus, handleItemBlur], + ); + + if (isLoading) { + return ( + + + + ); + } + + if (!libraries || libraries.length === 0) { + return ( + + + {t("library.no_libraries_found")} + + + ); + } + + return ( + + item.Id || ""} + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible", flexGrow: 0 }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + + ); +}; diff --git a/components/library/TVLibraryCard.tsx b/components/library/TVLibraryCard.tsx new file mode 100644 index 00000000..70918762 --- /dev/null +++ b/components/library/TVLibraryCard.tsx @@ -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["name"]; + +const icons: Record = { + 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 = ({ 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 ( + + {url && ( + + )} + + + + + {library.Name} + + {itemsCount !== undefined && ( + + {itemsCount} {itemTypeName} + + )} + + + ); +}; diff --git a/translations/en.json b/translations/en.json index dc24f9b0..d640c9a1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -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",