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",