feat(tv): enable video playlists library with square thumbnail grid

This commit is contained in:
Fredrik Burmester
2026-01-29 07:38:56 +01:00
parent 01298c9b6d
commit 80136f1800
3 changed files with 89 additions and 4 deletions

View File

@@ -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 (
<View
key={item.Id}
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
alignItems: "center",
}}
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
<View
style={{
width: TV_PLAYLIST_SQUARE_SIZE,
aspectRatio: 1,
borderRadius: 16,
overflow: "hidden",
backgroundColor: "#1a1a1a",
}}
>
<Image
source={playlistImageUrl ? { uri: playlistImageUrl } : null}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
</View>
</TVFocusablePoster>
<View style={{ marginTop: 12, alignItems: "center" }}>
<Text
numberOfLines={1}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
</View>
</View>
);
}
return (
<View
key={item.Id}
@@ -430,7 +498,7 @@ const Page = () => {
</View>
);
},
[router, showItemActions],
[router, showItemActions, api, typography],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -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! },

View File

@@ -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 {