feat(tv): add glass poster module and refactor grid layouts

This commit is contained in:
Fredrik Burmester
2026-01-25 17:02:10 +01:00
parent 2c6938c739
commit c2d61654b0
21 changed files with 980 additions and 130 deletions

View File

@@ -15,7 +15,13 @@ import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import {
FlatList,
Platform,
ScrollView,
useWindowDimensions,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
@@ -64,8 +70,9 @@ import {
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20;
const Page = () => {
const searchParams = useLocalSearchParams() as {
@@ -223,12 +230,8 @@ const Page = () => {
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
@@ -394,7 +397,7 @@ const Page = () => {
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
(item: BaseItemDto) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
@@ -402,9 +405,8 @@ const Page = () => {
return (
<View
key={item.Id}
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
@@ -843,15 +845,32 @@ const Page = () => {
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
onScroll={({ nativeEvent }) => {
// Load more when near bottom
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isNearBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - 500;
if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
@@ -918,45 +937,40 @@ const Page = () => {
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Text style={{ fontSize: 20, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{flatData.map((item) => renderTVItem(item))}
</View>
)}
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
);
};

View File

@@ -10,6 +10,7 @@ import {
Alert,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
useWindowDimensions,
View,
@@ -28,6 +29,7 @@ import MoviePoster, {
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import {
@@ -41,15 +43,24 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
<Text
style={{
fontSize: TVTypography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
@@ -70,14 +81,8 @@ export default function WatchlistDetailScreen() {
: undefined;
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
// TV uses flexWrap, so nrOfCols is just for mobile
if (Platform.isTV) return 1;
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -185,7 +190,7 @@ export default function WatchlistDetailScreen() {
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
(item: BaseItemDto, index: number) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
@@ -193,9 +198,8 @@ export default function WatchlistDetailScreen() {
return (
<View
key={item.Id}
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
@@ -328,6 +332,126 @@ export default function WatchlistDetailScreen() {
);
}
// TV layout with ScrollView + flexWrap
if (Platform.isTV) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
>
{/* Header */}
<View
style={{
alignItems: "center",
marginBottom: 32,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
}}
>
{watchlist.description && (
<Text
style={{
fontSize: TVTypography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
}}
>
{watchlist.description}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 24,
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={20}
color='#9ca3af'
/>
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text
style={{ fontSize: TVTypography.callout, color: "#737373" }}
>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
{/* Grid with flexWrap */}
{!items || items.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: TVTypography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,
}}
>
{t("watchlists.empty_watchlist")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{items.map((item, index) => renderTVItem(item, index))}
</View>
)}
</ScrollView>
);
}
// Mobile layout with FlashList
return (
<FlashList
key={orientation}
@@ -340,14 +464,13 @@ export default function WatchlistDetailScreen() {
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={Platform.isTV ? renderTVItem : renderItem}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View
style={{