mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-03 04:28:31 +01:00
On the Apple TV search page, the native tvOS search field owns focus while typing. Both result renderers set hasTVPreferredFocus on their first item, which re-requests focus on every re-render. Since results re-render on each keystroke, the first poster kept yanking focus out of the search field into the grid. Stop search results from requesting preferred focus in both Library and Discover modes; the user navigates down to the grid manually (standard tvOS .searchable behavior).
357 lines
11 KiB
TypeScript
357 lines
11 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 { ScrollView, 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 HORIZONTAL_PADDING = 60;
|
|
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 { 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,
|
|
}}
|
|
>
|
|
{/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
|
|
module). It renders the native search bar + grid keyboard and
|
|
forwards typed text into the existing query pipeline via setSearch;
|
|
our own results grid renders below. */}
|
|
<View
|
|
style={{
|
|
marginBottom: 24,
|
|
marginHorizontal: HORIZONTAL_PADDING,
|
|
height: SEARCH_AREA_HEIGHT,
|
|
}}
|
|
>
|
|
<TvSearchView
|
|
style={{ width: "100%", height: "100%" }}
|
|
placeholder={t("search.search")}
|
|
onChangeText={(e) => setSearch(e.nativeEvent.text)}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
<ScrollView
|
|
nestedScrollEnabled
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardDismissMode='on-drag'
|
|
contentContainerStyle={{
|
|
paddingBottom: insets.bottom + 60,
|
|
}}
|
|
>
|
|
{/* Search Type Tab Badges */}
|
|
{showDiscover && (
|
|
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
|
<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>
|
|
);
|
|
};
|