mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-29 17:20:30 +01:00
The TV search/discover page had three competing left-edge paddings: the Library/Discover badges used HORIZONTAL_PADDING (60), the Jellyseerr discover sections used SCALE_PADDING (20), while the rest of the app (home rows, library sections, loading skeleton) uses sizes.padding.horizontal. This left the filter badges visibly misaligned with the content grid below them. Unify the badges and the Jellyseerr discover/search sections onto sizes.padding.horizontal so the filter row, section headers, and posters share one edge — consistent with the home and library screens. Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { useAtom } from "jotai";
|
|
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Platform, ScrollView, TextInput, View } from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Text } from "@/components/common/Text";
|
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
|
import { useScaledTVSizes } from "@/constants/TVSizes";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import { TvSearchView } from "@/modules/tv-search";
|
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
|
import type {
|
|
MovieResult,
|
|
PersonResult,
|
|
TvResult,
|
|
} from "@/utils/jellyseerr/server/models/Search";
|
|
import { scaleSize } from "@/utils/scaleSize";
|
|
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
|
import { TVSearchSection } from "./TVSearchSection";
|
|
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
|
|
|
const TOP_PADDING = 100;
|
|
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
|
// its own overlay when the field is focused, so we only reserve the bar height
|
|
// here — not the whole keyboard. Tunable once seen on device.
|
|
const SEARCH_AREA_HEIGHT = 250;
|
|
const SECTION_GAP = 10;
|
|
const SCALE_PADDING = 20;
|
|
|
|
// Loading skeleton for TV.
|
|
// Mirrors TVSearchSection's scaled layout (poster width, item gap, edge
|
|
// padding, heading typography, poster radius) so the placeholder lines up with
|
|
// the real content that replaces it.
|
|
const TVLoadingSkeleton: React.FC = () => {
|
|
const typography = useScaledTVTypography();
|
|
const sizes = useScaledTVSizes();
|
|
const itemWidth = sizes.posters.poster;
|
|
return (
|
|
<View style={{ overflow: "visible" }}>
|
|
{/* Section header placeholder — matches the heading typography + margins */}
|
|
<View
|
|
style={{
|
|
width: itemWidth,
|
|
height: typography.heading,
|
|
backgroundColor: "#262626",
|
|
borderRadius: 8,
|
|
marginBottom: 20,
|
|
marginLeft: sizes.padding.horizontal,
|
|
}}
|
|
/>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
gap: sizes.gaps.item,
|
|
paddingLeft: sizes.padding.horizontal,
|
|
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: scaleSize(24),
|
|
marginBottom: scaleSize(8),
|
|
}}
|
|
/>
|
|
<View
|
|
style={{
|
|
borderRadius: 6,
|
|
overflow: "hidden",
|
|
alignSelf: "flex-start",
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: "#262626",
|
|
backgroundColor: "#262626",
|
|
borderRadius: 6,
|
|
fontSize: typography.callout,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
Placeholder text here
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
type SearchType = "Library" | "Discover";
|
|
|
|
interface TVSearchPageProps {
|
|
search: string;
|
|
setSearch: (text: string) => void;
|
|
debouncedSearch: string;
|
|
// Library search results
|
|
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;
|
|
onItemLongPress?: (item: BaseItemDto) => void;
|
|
// Jellyseerr/Discover props
|
|
searchType: SearchType;
|
|
setSearchType: (type: SearchType) => void;
|
|
showDiscover: boolean;
|
|
jellyseerrMovies?: MovieResult[];
|
|
jellyseerrTv?: TvResult[];
|
|
jellyseerrPersons?: PersonResult[];
|
|
jellyseerrLoading?: boolean;
|
|
jellyseerrNoResults?: boolean;
|
|
onJellyseerrMoviePress?: (item: MovieResult) => void;
|
|
onJellyseerrTvPress?: (item: TvResult) => void;
|
|
onJellyseerrPersonPress?: (item: PersonResult) => void;
|
|
// Discover sliders for empty state
|
|
discoverSliders?: DiscoverSlider[];
|
|
}
|
|
|
|
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|
setSearch,
|
|
debouncedSearch,
|
|
movies,
|
|
series,
|
|
episodes,
|
|
collections,
|
|
actors,
|
|
artists,
|
|
albums,
|
|
songs,
|
|
playlists,
|
|
loading,
|
|
noResults,
|
|
onItemPress,
|
|
onItemLongPress,
|
|
searchType,
|
|
setSearchType,
|
|
showDiscover,
|
|
jellyseerrMovies = [],
|
|
jellyseerrTv = [],
|
|
jellyseerrPersons = [],
|
|
jellyseerrLoading = false,
|
|
jellyseerrNoResults = false,
|
|
onJellyseerrMoviePress,
|
|
onJellyseerrTvPress,
|
|
onJellyseerrPersonPress,
|
|
discoverSliders,
|
|
}) => {
|
|
const typography = useScaledTVTypography();
|
|
const sizes = useScaledTVSizes();
|
|
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,
|
|
]);
|
|
|
|
const isLibraryMode = searchType === "Library";
|
|
const isDiscoverMode = searchType === "Discover";
|
|
const currentLoading = isLibraryMode ? loading : jellyseerrLoading;
|
|
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
|
|
|
return (
|
|
<View style={{ flex: 1 }}>
|
|
{/* Sticky header: search field stays pinned while results scroll below. */}
|
|
<View
|
|
style={{
|
|
paddingTop: insets.top + TOP_PADDING,
|
|
}}
|
|
>
|
|
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard
|
|
TextInput fallback on Android TV (the native module is Apple-only). */}
|
|
{Platform.OS === "ios" ? (
|
|
<View
|
|
style={{
|
|
marginBottom: 24,
|
|
height: SEARCH_AREA_HEIGHT,
|
|
}}
|
|
>
|
|
{/* No horizontal margin here: the native tvOS search bar centers
|
|
itself and renders a trailing "Hold to Dictate" hint. */}
|
|
<TvSearchView
|
|
style={{ width: "100%", height: "100%" }}
|
|
placeholder={t("search.search")}
|
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
|
/>
|
|
</View>
|
|
) : (
|
|
<View
|
|
style={{
|
|
marginHorizontal: sizes.padding.horizontal,
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
<TextInput
|
|
style={{
|
|
height: 56,
|
|
width: "100%",
|
|
backgroundColor: "#262626",
|
|
borderRadius: 12,
|
|
paddingHorizontal: 20,
|
|
fontSize: 28,
|
|
color: "#fff",
|
|
}}
|
|
placeholder={t("search.search")}
|
|
placeholderTextColor='rgba(255,255,255,0.4)'
|
|
onChangeText={setSearch}
|
|
defaultValue=''
|
|
autoFocus={false}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<ScrollView
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardDismissMode='on-drag'
|
|
contentContainerStyle={{
|
|
paddingBottom: insets.bottom + 60,
|
|
}}
|
|
>
|
|
{/* Search Type Tab Badges */}
|
|
{showDiscover && (
|
|
<View style={{ marginHorizontal: sizes.padding.horizontal }}>
|
|
<TVSearchTabBadges
|
|
searchType={searchType}
|
|
setSearchType={setSearchType}
|
|
showDiscover={showDiscover}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{currentLoading && (
|
|
<View style={{ gap: SECTION_GAP }}>
|
|
<TVLoadingSkeleton />
|
|
<TVLoadingSkeleton />
|
|
</View>
|
|
)}
|
|
|
|
{/* Library Search Results */}
|
|
{isLibraryMode && !loading && (
|
|
<View style={{ gap: SECTION_GAP }}>
|
|
{sections.map((section) => (
|
|
<TVSearchSection
|
|
key={section.key}
|
|
title={section.title}
|
|
items={section.items!}
|
|
orientation={section.orientation || "vertical"}
|
|
// Never auto-focus a result. The native search field owns focus
|
|
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
|
// every keystroke as results re-render. User navigates down to the
|
|
// grid manually.
|
|
isFirstSection={false}
|
|
onItemPress={onItemPress}
|
|
onItemLongPress={onItemLongPress}
|
|
imageUrlGetter={
|
|
["artists", "albums", "songs", "playlists"].includes(
|
|
section.key,
|
|
)
|
|
? getImageUrl
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{/* Jellyseerr/Discover Search Results */}
|
|
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
|
<TVJellyseerrSearchResults
|
|
movieResults={jellyseerrMovies}
|
|
tvResults={jellyseerrTv}
|
|
personResults={jellyseerrPersons}
|
|
loading={jellyseerrLoading}
|
|
noResults={jellyseerrNoResults}
|
|
searchQuery={debouncedSearch}
|
|
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
|
onTvPress={onJellyseerrTvPress || (() => {})}
|
|
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
|
/>
|
|
)}
|
|
|
|
{/* Discover Content (when no search query in Discover mode) */}
|
|
{isDiscoverMode &&
|
|
!jellyseerrLoading &&
|
|
debouncedSearch.length === 0 && (
|
|
<TVDiscover sliders={discoverSliders} />
|
|
)}
|
|
|
|
{/* No Results State */}
|
|
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.heading,
|
|
fontWeight: "bold",
|
|
color: "#FFFFFF",
|
|
marginBottom: 8,
|
|
}}
|
|
>
|
|
{t("search.no_results_found_for")}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: typography.body,
|
|
color: "rgba(255,255,255,0.6)",
|
|
}}
|
|
>
|
|
"{debouncedSearch}"
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
};
|