diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 9fe9733c..2ac4c58e 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -11,6 +11,7 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo } from "react"; @@ -70,10 +71,12 @@ import { } from "@/utils/atoms/filters"; import { useSettings } from "@/utils/atoms/settings"; import type { TVOptionItem } from "@/utils/atoms/tvOptionModal"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; const TV_ITEM_GAP = 20; const TV_HORIZONTAL_PADDING = 60; const _TV_SCALE_PADDING = 20; +const TV_PLAYLIST_SQUARE_SIZE = 180; const Page = () => { const searchParams = useLocalSearchParams() as { @@ -288,6 +291,8 @@ const Page = () => { itemType = "Video"; } else if (library.CollectionType === "musicvideos") { itemType = "MusicVideo"; + } else if (library.CollectionType === "playlists") { + itemType = "Playlist"; } const response = await getItemsApi(api).getItems({ @@ -307,6 +312,9 @@ const Page = () => { tags: selectedTags, years: selectedYears.map((year) => Number.parseInt(year, 10)), includeItemTypes: itemType ? [itemType] : undefined, + ...(Platform.isTV && library.CollectionType === "playlists" + ? { mediaTypes: ["Video"] } + : {}), }); return response.data || null; @@ -403,10 +411,70 @@ const Page = () => { const renderTVItem = useCallback( (item: BaseItemDto) => { const handlePress = () => { + if (item.Type === "Playlist") { + router.push({ + pathname: "/(auth)/(tabs)/(libraries)/[libraryId]", + params: { libraryId: item.Id! }, + }); + return; + } const navTarget = getItemNavigation(item, "(libraries)"); router.push(navTarget as any); }; + // Special rendering for Playlist items (square thumbnails) + if (item.Type === "Playlist") { + const playlistImageUrl = getPrimaryImageUrl({ + api, + item, + width: TV_PLAYLIST_SQUARE_SIZE * 2, + }); + + return ( + + showItemActions(item)} + > + + + + + + + {item.Name} + + + + ); + } + return ( { ); }, - [router, showItemActions], + [router, showItemActions, api, typography], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 2c85e094..cc40d2dc 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -2,7 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useSegments } from "expo-router"; import { type PropsWithChildren, useCallback } from "react"; -import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; +import { + Platform, + TouchableOpacity, + type TouchableOpacityProps, +} from "react-native"; import useRouter from "@/hooks/useAppRouter"; import { useFavorite } from "@/hooks/useFavorite"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; @@ -121,6 +125,12 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => { } if (item.Type === "Playlist") { + if (Platform.isTV) { + return { + pathname: "/[libraryId]" as const, + params: { libraryId: item.Id! }, + }; + } return { pathname: "/music/playlist/[playlistId]" as const, params: { playlistId: item.Id! }, diff --git a/components/library/TVLibraries.tsx b/components/library/TVLibraries.tsx index 4c985dc0..8bcff9da 100644 --- a/components/library/TVLibraries.tsx +++ b/components/library/TVLibraries.tsx @@ -103,6 +103,8 @@ const TVLibraryRow: React.FC<{ return t("library.item_types.series"); if (library.CollectionType === "boxsets") return t("library.item_types.boxsets"); + if (library.CollectionType === "playlists") + return t("library.item_types.playlists"); if (library.CollectionType === "music") return t("library.item_types.items"); return t("library.item_types.items"); @@ -258,8 +260,7 @@ export const TVLibraries: React.FC = () => { userViews ?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)) .filter((l) => l.CollectionType !== "books") - .filter((l) => l.CollectionType !== "music") - .filter((l) => l.CollectionType !== "playlists") || [], + .filter((l) => l.CollectionType !== "music") || [], [userViews, settings?.hiddenLibraries], ); @@ -273,6 +274,10 @@ export const TVLibraries: React.FC = () => { if (library.CollectionType === "movies") itemType = "Movie"; else if (library.CollectionType === "tvshows") itemType = "Series"; else if (library.CollectionType === "boxsets") itemType = "BoxSet"; + else if (library.CollectionType === "playlists") + itemType = "Playlist"; + + const isPlaylistsLib = library.CollectionType === "playlists"; // Fetch count const countResponse = await getItemsApi(api!).getItems({ @@ -281,6 +286,7 @@ export const TVLibraries: React.FC = () => { recursive: true, limit: 0, includeItemTypes: itemType ? [itemType as any] : undefined, + ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}), }); // Fetch preview items with backdrops @@ -292,6 +298,7 @@ export const TVLibraries: React.FC = () => { sortBy: ["Random"], includeItemTypes: itemType ? [itemType as any] : undefined, imageTypes: ["Backdrop"], + ...(isPlaylistsLib ? { mediaTypes: ["Video"] } : {}), }); return {