mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-24 03:58:08 +00:00
wip
This commit is contained in:
51
CLAUDE.md
51
CLAUDE.md
@@ -252,3 +252,54 @@ const TVFocusableButton: React.FC<{
|
||||
```
|
||||
|
||||
**Reference implementation**: See `settings.tv.tsx` for complete example with `TVSettingsOptionButton`, `TVSettingsToggle`, `TVSettingsStepper`, etc.
|
||||
|
||||
### TV Focus Flickering Between Zones (Lists with Headers)
|
||||
|
||||
When you have a page with multiple focusable zones (e.g., a filter bar above a grid), the TV focus engine can rapidly flicker between elements when navigating between zones. This is a known issue with React Native TV.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Use FlatList instead of FlashList for TV** - FlashList has known focus issues on TV platforms. Use regular FlatList with `Platform.isTV` check:
|
||||
```typescript
|
||||
{Platform.isTV ? (
|
||||
<FlatList
|
||||
data={items}
|
||||
renderItem={renderTVItem}
|
||||
removeClippedSubviews={false}
|
||||
// ...
|
||||
/>
|
||||
) : (
|
||||
<FlashList data={items} renderItem={renderItem} />
|
||||
)}
|
||||
```
|
||||
|
||||
2. **Add `removeClippedSubviews={false}`** - Prevents the list from unmounting off-screen items, which can cause focus to "fall through" to other elements.
|
||||
|
||||
3. **Only ONE element should have `hasTVPreferredFocus`** - Never have multiple elements competing for initial focus. Choose one element (usually the first filter button or first list item) to have preferred focus:
|
||||
```typescript
|
||||
// ✅ Good - only first filter button has preferred focus
|
||||
<TVFilterButton hasTVPreferredFocus={index === 0} />
|
||||
<TVFocusablePoster /> // No hasTVPreferredFocus
|
||||
|
||||
// ❌ Bad - both compete for focus
|
||||
<TVFilterButton hasTVPreferredFocus />
|
||||
<TVFocusablePoster hasTVPreferredFocus={index === 0} />
|
||||
```
|
||||
|
||||
4. **Keep headers/filter bars outside the list** - Instead of using `ListHeaderComponent`, render the filter bar as a separate View above the FlatList:
|
||||
```typescript
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Filter bar - separate from list */}
|
||||
<View style={{ flexDirection: "row", gap: 12 }}>
|
||||
<TVFilterButton />
|
||||
<TVFilterButton />
|
||||
</View>
|
||||
|
||||
{/* Grid */}
|
||||
<FlatList data={items} renderItem={renderTVItem} />
|
||||
</View>
|
||||
```
|
||||
|
||||
5. **Avoid multiple scrollable containers** - Don't use ScrollView for the filter bar if you have a FlatList below. Use a simple View instead to prevent focus conflicts between scrollable containers.
|
||||
|
||||
**Reference implementation**: See `app/(auth)/(tabs)/(libraries)/[libraryId].tsx` for the TV filter bar + grid pattern.
|
||||
|
||||
@@ -223,7 +223,7 @@ const TVFilterButton: React.FC<{
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: hasActiveFilter
|
||||
? "rgba(147, 51, 234, 0.3)"
|
||||
? "rgba(255, 255, 255, 0.25)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
@@ -232,12 +232,14 @@ const TVFilterButton: React.FC<{
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
||||
borderColor: "rgba(147, 51, 234, 0.5)",
|
||||
borderColor: "rgba(255, 255, 255, 0.4)",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
|
||||
{label}
|
||||
</Text>
|
||||
{label ? (
|
||||
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
@@ -260,28 +262,16 @@ const TVFilterSelector = <T,>({
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
multiSelect = false,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVFilterOption<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
multiSelect?: boolean;
|
||||
}) => {
|
||||
const [doneButtonFocused, setDoneButtonFocused] = useState(false);
|
||||
const doneScale = useRef(new Animated.Value(1)).current;
|
||||
// Track initial focus index - only set once when modal opens
|
||||
const initialFocusIndexRef = useRef<number | null>(null);
|
||||
|
||||
const animateDone = (v: number) =>
|
||||
Animated.timing(doneScale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
// Calculate initial focus index only once when visible becomes true
|
||||
if (visible && initialFocusIndexRef.current === null) {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
@@ -319,54 +309,17 @@ const TVFilterSelector = <T,>({
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingVertical: 24 }}>
|
||||
<View
|
||||
<Text
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, fontWeight: "600", color: "#fff" }}>
|
||||
{title}
|
||||
</Text>
|
||||
{multiSelect && (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
onFocus={() => {
|
||||
setDoneButtonFocused(true);
|
||||
animateDone(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setDoneButtonFocused(false);
|
||||
animateDone(1);
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: doneScale }],
|
||||
backgroundColor: doneButtonFocused
|
||||
? "#fff"
|
||||
: "rgba(255,255,255,0.2)",
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
color: doneButtonFocused ? "#000" : "#fff",
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{title}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -385,9 +338,7 @@ const TVFilterSelector = <T,>({
|
||||
hasTVPreferredFocus={index === initialFocusIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
if (!multiSelect) {
|
||||
onClose();
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -435,6 +386,8 @@ const Page = () => {
|
||||
useState<TVFilterModalType>(null);
|
||||
const isFilterModalOpen = openFilterModal !== null;
|
||||
|
||||
const isFiltersDisabled = isFilterModalOpen;
|
||||
|
||||
// TV Filter queries
|
||||
const { data: tvGenreOptions } = useQuery({
|
||||
queryKey: ["filters", "Genres", "tvGenreFilter", libraryId],
|
||||
@@ -729,7 +682,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const renderTVItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
const handlePress = () => {
|
||||
const navTarget = getItemNavigation(item, "(libraries)");
|
||||
router.push(navTarget as any);
|
||||
@@ -743,11 +696,7 @@ const Page = () => {
|
||||
width: TV_POSTER_WIDTH,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster
|
||||
onPress={handlePress}
|
||||
hasTVPreferredFocus={index === 0 && !isFilterModalOpen}
|
||||
disabled={isFilterModalOpen}
|
||||
>
|
||||
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||
<SeriesPoster item={item} />
|
||||
@@ -961,35 +910,53 @@ const Page = () => {
|
||||
_setFilterBy([]);
|
||||
}, [setSelectedGenres, setSelectedYears, setSelectedTags, _setFilterBy]);
|
||||
|
||||
// TV Filter options
|
||||
// TV Filter options - with "All" option for clearable filters
|
||||
const tvGenreFilterOptions = useMemo(
|
||||
(): TVFilterOption<string>[] =>
|
||||
(tvGenreOptions || []).map((genre) => ({
|
||||
(): TVFilterOption<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedGenres.length === 0,
|
||||
},
|
||||
...(tvGenreOptions || []).map((genre) => ({
|
||||
label: genre,
|
||||
value: genre,
|
||||
selected: selectedGenres.includes(genre),
|
||||
})),
|
||||
[tvGenreOptions, selectedGenres],
|
||||
],
|
||||
[tvGenreOptions, selectedGenres, t],
|
||||
);
|
||||
|
||||
const tvYearFilterOptions = useMemo(
|
||||
(): TVFilterOption<string>[] =>
|
||||
(tvYearOptions || []).map((year) => ({
|
||||
(): TVFilterOption<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedYears.length === 0,
|
||||
},
|
||||
...(tvYearOptions || []).map((year) => ({
|
||||
label: String(year),
|
||||
value: String(year),
|
||||
selected: selectedYears.includes(String(year)),
|
||||
})),
|
||||
[tvYearOptions, selectedYears],
|
||||
],
|
||||
[tvYearOptions, selectedYears, t],
|
||||
);
|
||||
|
||||
const tvTagFilterOptions = useMemo(
|
||||
(): TVFilterOption<string>[] =>
|
||||
(tvTagOptions || []).map((tag) => ({
|
||||
(): TVFilterOption<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: selectedTags.length === 0,
|
||||
},
|
||||
...(tvTagOptions || []).map((tag) => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
selected: selectedTags.includes(tag),
|
||||
})),
|
||||
[tvTagOptions, selectedTags],
|
||||
],
|
||||
[tvTagOptions, selectedTags, t],
|
||||
);
|
||||
|
||||
const tvSortByOptions = useMemo(
|
||||
@@ -1013,19 +980,27 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const tvFilterByOptions = useMemo(
|
||||
(): TVFilterOption<FilterByOption>[] =>
|
||||
generalFilters.map((option) => ({
|
||||
(): TVFilterOption<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
selected: filterBy.length === 0,
|
||||
},
|
||||
...generalFilters.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: filterBy.includes(option.key),
|
||||
})),
|
||||
[filterBy, generalFilters],
|
||||
],
|
||||
[filterBy, generalFilters, t],
|
||||
);
|
||||
|
||||
// TV Filter handlers
|
||||
const handleGenreSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (selectedGenres.includes(value)) {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
@@ -1036,7 +1011,9 @@ const Page = () => {
|
||||
|
||||
const handleYearSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (selectedYears.includes(value)) {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
@@ -1047,7 +1024,9 @@ const Page = () => {
|
||||
|
||||
const handleTagSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (selectedTags.includes(value)) {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
@@ -1056,6 +1035,17 @@ const Page = () => {
|
||||
[selectedTags, setSelectedTags],
|
||||
);
|
||||
|
||||
const handleFilterBySelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
_setFilterBy([]);
|
||||
} else {
|
||||
setFilter([value as FilterByOption]);
|
||||
}
|
||||
},
|
||||
[setFilter, _setFilterBy],
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
@@ -1126,7 +1116,7 @@ const Page = () => {
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "nowrap",
|
||||
marginTop: insets.top + 20,
|
||||
marginTop: insets.top + 100,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: TV_SCALE_PADDING,
|
||||
gap: 12,
|
||||
@@ -1137,7 +1127,7 @@ const Page = () => {
|
||||
label=''
|
||||
value={t("library.filters.reset")}
|
||||
onPress={resetAllFilters}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter
|
||||
/>
|
||||
)}
|
||||
@@ -1150,7 +1140,7 @@ const Page = () => {
|
||||
}
|
||||
onPress={() => setOpenFilterModal("genre")}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
@@ -1161,7 +1151,7 @@ const Page = () => {
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("year")}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
@@ -1172,14 +1162,14 @@ const Page = () => {
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("tags")}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedTags.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_by")}
|
||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||
onPress={() => setOpenFilterModal("sortBy")}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
@@ -1187,7 +1177,7 @@ const Page = () => {
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={() => setOpenFilterModal("sortOrder")}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.filter_by")}
|
||||
@@ -1197,7 +1187,7 @@ const Page = () => {
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("filterBy")}
|
||||
disabled={isFilterModalOpen}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={filterBy.length > 0}
|
||||
/>
|
||||
</View>
|
||||
@@ -1229,7 +1219,7 @@ const Page = () => {
|
||||
paddingBottom: 24,
|
||||
paddingLeft: TV_SCALE_PADDING,
|
||||
paddingRight: TV_SCALE_PADDING,
|
||||
paddingTop: 8,
|
||||
paddingTop: 20,
|
||||
}}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
@@ -1249,7 +1239,6 @@ const Page = () => {
|
||||
options={tvGenreFilterOptions}
|
||||
onSelect={handleGenreSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
multiSelect
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "year"}
|
||||
@@ -1257,7 +1246,6 @@ const Page = () => {
|
||||
options={tvYearFilterOptions}
|
||||
onSelect={handleYearSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
multiSelect
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "tags"}
|
||||
@@ -1265,7 +1253,6 @@ const Page = () => {
|
||||
options={tvTagFilterOptions}
|
||||
onSelect={handleTagSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
multiSelect
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "sortBy"}
|
||||
@@ -1285,7 +1272,7 @@ const Page = () => {
|
||||
visible={openFilterModal === "filterBy"}
|
||||
title={t("library.filters.filter_by")}
|
||||
options={tvFilterByOptions}
|
||||
onSelect={(value) => setFilter([value])}
|
||||
onSelect={handleFilterBySelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -22,9 +22,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import {
|
||||
getItemNavigation,
|
||||
TouchableItemRouter,
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
@@ -36,6 +38,7 @@ import { DiscoverFilters } from "@/components/search/DiscoverFilters";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -59,6 +62,8 @@ export default function search() {
|
||||
const params = useLocalSearchParams();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(search)";
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
@@ -438,6 +443,38 @@ export default function search() {
|
||||
return l1 || l2 || l3 || l7 || l8 || l9 || l10 || l11 || l12;
|
||||
}, [l1, l2, l3, l7, l8, l9, l10, l11, l12]);
|
||||
|
||||
// TV item press handler
|
||||
const handleItemPress = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[from, router],
|
||||
);
|
||||
|
||||
// Render TV search page
|
||||
if (Platform.isTV) {
|
||||
return (
|
||||
<TVSearchPage
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
debouncedSearch={debouncedSearch}
|
||||
movies={movies}
|
||||
series={series}
|
||||
episodes={episodes}
|
||||
collections={collections}
|
||||
actors={actors}
|
||||
artists={artists}
|
||||
albums={albums}
|
||||
songs={songs}
|
||||
playlists={playlists}
|
||||
loading={loading}
|
||||
noResults={noResults}
|
||||
onItemPress={handleItemPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
keyboardDismissMode='on-drag'
|
||||
@@ -448,30 +485,6 @@ export default function search() {
|
||||
paddingBottom: 60,
|
||||
}}
|
||||
>
|
||||
{/* <View
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
> */}
|
||||
{Platform.isTV && (
|
||||
<View
|
||||
style={{ paddingHorizontal: 48, paddingTop: 0, paddingBottom: 8 }}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
onChangeText={(text) => {
|
||||
router.setParams({ q: "" });
|
||||
setSearch(text);
|
||||
}}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
className='flex flex-col'
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
|
||||
@@ -122,7 +122,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.08);
|
||||
animateTo(1.03);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
@@ -132,10 +132,10 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#a855f7",
|
||||
shadowColor: color === "black" ? "#ffffff" : "#a855f7",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.9 : 0,
|
||||
shadowRadius: focused ? 18 : 0,
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 10 : 0,
|
||||
elevation: focused ? 12 : 0, // Android glow
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
Pressable,
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
interface TVInputProps extends TextInputProps {
|
||||
label?: string;
|
||||
@@ -16,6 +14,7 @@ interface TVInputProps extends TextInputProps {
|
||||
|
||||
export const TVInput: React.FC<TVInputProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
hasTVPreferredFocus,
|
||||
style,
|
||||
...props
|
||||
@@ -43,94 +42,40 @@ export const TVInput: React.FC<TVInputProps> = ({
|
||||
animateFocus(false);
|
||||
};
|
||||
|
||||
const displayPlaceholder = placeholder || label;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{label && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: isFocused ? "#FFFFFF" : "#9CA3AF",
|
||||
marginBottom: 8,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
<Pressable
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
borderRadius: 10,
|
||||
borderWidth: 3,
|
||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
}}
|
||||
>
|
||||
{/* Outer glow layer - only visible when focused */}
|
||||
{isFocused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
backgroundColor: "#9334E9",
|
||||
borderRadius: 20,
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main input container */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isFocused ? "#3a3a3a" : "#1a1a1a",
|
||||
borderWidth: 3,
|
||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Inner highlight bar when focused */}
|
||||
{isFocused && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
backgroundColor: "#9334E9",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
allowFontScaling={false}
|
||||
placeholderTextColor={isFocused ? "#AAAAAA" : "#666666"}
|
||||
style={[
|
||||
{
|
||||
height: 68,
|
||||
fontSize: 26,
|
||||
fontWeight: "500",
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: isFocused ? 6 : 0,
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={displayPlaceholder}
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
{
|
||||
height: 68,
|
||||
fontSize: 24,
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
{...props}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.parallel([
|
||||
Animated.timing(scale, {
|
||||
toValue: focused ? 1.03 : 1,
|
||||
toValue: focused ? 1.02 : 1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
@@ -87,12 +87,14 @@ export const TVSaveAccountToggle: React.FC<TVSaveAccountToggleProps> = ({
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
<View pointerEvents='none'>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: "#3f3f46", true: Colors.primary }}
|
||||
thumbColor='white'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
const animateFocus = (focused: boolean) => {
|
||||
Animated.parallel([
|
||||
Animated.timing(scale, {
|
||||
toValue: focused ? 1.05 : 1,
|
||||
toValue: focused ? 1.02 : 1,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
|
||||
307
components/search/TVSearchPage.tsx
Normal file
307
components/search/TVSearchPage.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { TVSearchSection } from "./TVSearchSection";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
const SECTION_GAP = 10;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
// Loading skeleton for TV
|
||||
const TVLoadingSkeleton: React.FC = () => {
|
||||
const itemWidth = 210;
|
||||
return (
|
||||
<View style={{ overflow: "visible" }}>
|
||||
<View
|
||||
style={{
|
||||
width: 200,
|
||||
height: 28,
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<View key={i} style={{ width: itemWidth }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#262626",
|
||||
width: itemWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
marginBottom: 4,
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "#262626",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 6,
|
||||
fontSize: 16,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
Placeholder text here
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Example search suggestions for TV
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
|
||||
interface TVSearchPageProps {
|
||||
search: string;
|
||||
setSearch: (text: string) => void;
|
||||
debouncedSearch: string;
|
||||
movies?: BaseItemDto[];
|
||||
series?: BaseItemDto[];
|
||||
episodes?: BaseItemDto[];
|
||||
collections?: BaseItemDto[];
|
||||
actors?: BaseItemDto[];
|
||||
artists?: BaseItemDto[];
|
||||
albums?: BaseItemDto[];
|
||||
songs?: BaseItemDto[];
|
||||
playlists?: BaseItemDto[];
|
||||
loading: boolean;
|
||||
noResults: boolean;
|
||||
onItemPress: (item: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
search,
|
||||
setSearch,
|
||||
debouncedSearch,
|
||||
movies,
|
||||
series,
|
||||
episodes,
|
||||
collections,
|
||||
actors,
|
||||
artists,
|
||||
albums,
|
||||
songs,
|
||||
playlists,
|
||||
loading,
|
||||
noResults,
|
||||
onItemPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
// Image URL getter for music items
|
||||
const getImageUrl = useMemo(() => {
|
||||
return (item: BaseItemDto): string | undefined => {
|
||||
if (!api) return undefined;
|
||||
const url = getPrimaryImageUrl({ api, item });
|
||||
return url ?? undefined;
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Determine which section should have initial focus
|
||||
const sections = useMemo(() => {
|
||||
const allSections: {
|
||||
key: string;
|
||||
title: string;
|
||||
items: BaseItemDto[] | undefined;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}[] = [
|
||||
{ key: "movies", title: t("search.movies"), items: movies },
|
||||
{ key: "series", title: t("search.series"), items: series },
|
||||
{
|
||||
key: "episodes",
|
||||
title: t("search.episodes"),
|
||||
items: episodes,
|
||||
orientation: "horizontal" as const,
|
||||
},
|
||||
{
|
||||
key: "collections",
|
||||
title: t("search.collections"),
|
||||
items: collections,
|
||||
},
|
||||
{ key: "actors", title: t("search.actors"), items: actors },
|
||||
{ key: "artists", title: t("search.artists"), items: artists },
|
||||
{ key: "albums", title: t("search.albums"), items: albums },
|
||||
{ key: "songs", title: t("search.songs"), items: songs },
|
||||
{ key: "playlists", title: t("search.playlists"), items: playlists },
|
||||
];
|
||||
|
||||
return allSections.filter((s) => s.items && s.items.length > 0);
|
||||
}, [
|
||||
movies,
|
||||
series,
|
||||
episodes,
|
||||
collections,
|
||||
actors,
|
||||
artists,
|
||||
albums,
|
||||
songs,
|
||||
playlists,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ marginBottom: 32, marginHorizontal: SCALE_PADDING }}>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
hasTVPreferredFocus={
|
||||
debouncedSearch.length === 0 && sections.length === 0
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{!loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
imageUrlGetter={
|
||||
["artists", "albums", "songs", "playlists"].includes(
|
||||
section.key,
|
||||
)
|
||||
? getImageUrl
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!loading && noResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "#9334E9" }}>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Example Searches (when no search query) */}
|
||||
{!loading && debouncedSearch.length === 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40, gap: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.search")}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{exampleSearches.map((example) => (
|
||||
<Pressable
|
||||
key={example}
|
||||
onPress={() => setSearch(example)}
|
||||
style={({ focused }) => ({
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 24,
|
||||
backgroundColor: focused
|
||||
? "#9334E9"
|
||||
: "rgba(255, 255, 255, 0.1)",
|
||||
transform: [{ scale: focused ? 1.05 : 1 }],
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
{example}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
344
components/search/TVSearchSection.tsx
Normal file
344
components/search/TVSearchSection.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FlatList, View, type ViewProps } from "react-native";
|
||||
import ContinueWatchingPoster, {
|
||||
TV_LANDSCAPE_WIDTH,
|
||||
} from "@/components/ContinueWatchingPoster.tv";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
|
||||
const ITEM_GAP = 16;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
// TV-specific ItemCardText with larger fonts
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
>
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "MusicArtist" ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : item.Type === "MusicAlbum" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
>
|
||||
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Audio" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Playlist" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ChildCount} tracks
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Person" ? (
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVSearchSectionProps extends ViewProps {
|
||||
title: string;
|
||||
items: BaseItemDto[];
|
||||
orientation?: "horizontal" | "vertical";
|
||||
disabled?: boolean;
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: BaseItemDto) => void;
|
||||
imageUrlGetter?: (item: BaseItemDto) => string | undefined;
|
||||
}
|
||||
|
||||
export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
orientation = "vertical",
|
||||
disabled = false,
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
imageUrlGetter,
|
||||
...props
|
||||
}) => {
|
||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||
const [focusedCount, setFocusedCount] = useState(0);
|
||||
const prevFocusedCount = useRef(0);
|
||||
|
||||
// When section loses all focus, scroll back to start
|
||||
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 itemWidth =
|
||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_data: ArrayLike<BaseItemDto> | null | undefined, index: number) => ({
|
||||
length: itemWidth + ITEM_GAP,
|
||||
offset: (itemWidth + ITEM_GAP) * index,
|
||||
index,
|
||||
}),
|
||||
[itemWidth],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||
const isFirstItem = isFirstSection && index === 0;
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
||||
const renderPoster = () => {
|
||||
// Music Artist - circular avatar
|
||||
if (item.Type === "MusicArtist") {
|
||||
const imageUrl = imageUrlGetter?.(item);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#262626",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 48 }}>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Music Album, Audio, Playlist - square images
|
||||
if (
|
||||
item.Type === "MusicAlbum" ||
|
||||
item.Type === "Audio" ||
|
||||
item.Type === "Playlist"
|
||||
) {
|
||||
const imageUrl = imageUrlGetter?.(item);
|
||||
const icon =
|
||||
item.Type === "Playlist"
|
||||
? "🎶"
|
||||
: item.Type === "Audio"
|
||||
? "🎵"
|
||||
: "🎵";
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: TV_POSTER_WIDTH,
|
||||
height: TV_POSTER_WIDTH,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#262626",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 64 }}>{icon}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Person (Actor)
|
||||
if (item.Type === "Person") {
|
||||
return <MoviePoster item={item} />;
|
||||
}
|
||||
|
||||
// Episode rendering
|
||||
if (item.Type === "Episode" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
if (item.Type === "Episode" && !isHorizontal) {
|
||||
return <SeriesPoster item={item} />;
|
||||
}
|
||||
|
||||
// Movie rendering
|
||||
if (item.Type === "Movie" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
if (item.Type === "Movie" && !isHorizontal) {
|
||||
return <MoviePoster item={item} />;
|
||||
}
|
||||
|
||||
// Series rendering
|
||||
if (item.Type === "Series" && !isHorizontal) {
|
||||
return <SeriesPoster item={item} />;
|
||||
}
|
||||
if (item.Type === "Series" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
|
||||
// BoxSet (Collection)
|
||||
if (item.Type === "BoxSet" && !isHorizontal) {
|
||||
return <MoviePoster item={item} />;
|
||||
}
|
||||
if (item.Type === "BoxSet" && isHorizontal) {
|
||||
return <ContinueWatchingPoster item={item} />;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return isHorizontal ? (
|
||||
<ContinueWatchingPoster item={item} />
|
||||
) : (
|
||||
<MoviePoster item={item} />
|
||||
);
|
||||
};
|
||||
|
||||
// Special width for music artists (circular)
|
||||
const actualItemWidth = item.Type === "MusicArtist" ? 160 : itemWidth;
|
||||
|
||||
return (
|
||||
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
|
||||
<TVFocusablePoster
|
||||
onPress={() => onItemPress(item)}
|
||||
hasTVPreferredFocus={isFirstItem && !disabled}
|
||||
onFocus={handleItemFocus}
|
||||
onBlur={handleItemBlur}
|
||||
disabled={disabled}
|
||||
>
|
||||
{renderPoster()}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
orientation,
|
||||
isFirstSection,
|
||||
itemWidth,
|
||||
onItemPress,
|
||||
handleItemFocus,
|
||||
handleItemBlur,
|
||||
disabled,
|
||||
imageUrlGetter,
|
||||
],
|
||||
);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ overflow: "visible" }} {...props}>
|
||||
{/* Section Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.Id!}
|
||||
renderItem={renderItem}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingVertical: SCALE_PADDING,
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -577,7 +577,11 @@
|
||||
"sort_by": "Sort By",
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Sort Order",
|
||||
"tags": "Tags"
|
||||
"tags": "Tags",
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
|
||||
@@ -574,7 +574,11 @@
|
||||
"sort_by": "Sortera efter",
|
||||
"filter_by": "Filtrera På",
|
||||
"sort_order": "Sorteringsordning",
|
||||
"tags": "Etiketter"
|
||||
"tags": "Etiketter",
|
||||
"all": "Alla",
|
||||
"reset": "Återställ",
|
||||
"asc": "Stigande",
|
||||
"desc": "Fallande"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
|
||||
Reference in New Issue
Block a user