mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-27 13:38:28 +00:00
Compare commits
22 Commits
feat/tv-in
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
715daf1635 | ||
|
|
44caf4b1ff | ||
|
|
92c70fadd1 | ||
|
|
f637367b82 | ||
|
|
715764cef8 | ||
|
|
36d6686258 | ||
|
|
dca7cc99f2 | ||
|
|
875a017e8c | ||
|
|
0c6c20f563 | ||
|
|
2c9906377d | ||
|
|
d5f7a18fe5 | ||
|
|
4606b9718e | ||
|
|
c2d61654b0 | ||
|
|
2c6938c739 | ||
|
|
1f454c0f12 | ||
|
|
c215fda973 | ||
|
|
a852e2e769 | ||
|
|
29873e08d7 | ||
|
|
5ce5cc2d99 | ||
|
|
ae5a71ff29 | ||
|
|
0e3e8b8016 | ||
|
|
d07a521f60 |
@@ -30,4 +30,14 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
|
||||
|
||||
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
||||
|
||||
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||
|
||||
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
|
||||
|
||||
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
|
||||
|
||||
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
|
||||
|
||||
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
|
||||
|
||||
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||
@@ -1,15 +1,6 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Home } from "../../../../components/home/Home";
|
||||
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||
|
||||
const Index = () => {
|
||||
const { settings } = useSettings();
|
||||
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||
|
||||
if (showLargeHomeCarousel) {
|
||||
return <HomeWithCarousel />;
|
||||
}
|
||||
|
||||
return <Home />;
|
||||
};
|
||||
|
||||
|
||||
@@ -15,9 +15,14 @@ import {
|
||||
TVSettingsTextInput,
|
||||
TVSettingsToggle,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
AudioTranscodeMode,
|
||||
TVTypographyScale,
|
||||
useSettings,
|
||||
} from "@/utils/atoms/settings";
|
||||
|
||||
export default function SettingsTV() {
|
||||
const { t } = useTranslation();
|
||||
@@ -27,6 +32,7 @@ export default function SettingsTV() {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { showOptions } = useTVOptionModal();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
// Local state for OpenSubtitles API key (only commit on blur)
|
||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||
@@ -39,6 +45,8 @@ export default function SettingsTV() {
|
||||
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||
const currentTypographyScale =
|
||||
settings.tvTypographyScale || TVTypographyScale.Default;
|
||||
|
||||
// Audio transcoding options
|
||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||
@@ -130,6 +138,33 @@ export default function SettingsTV() {
|
||||
[currentAlignY],
|
||||
);
|
||||
|
||||
// Typography scale options
|
||||
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_small"),
|
||||
value: TVTypographyScale.Small,
|
||||
selected: currentTypographyScale === TVTypographyScale.Small,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_default"),
|
||||
value: TVTypographyScale.Default,
|
||||
selected: currentTypographyScale === TVTypographyScale.Default,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_large"),
|
||||
value: TVTypographyScale.Large,
|
||||
selected: currentTypographyScale === TVTypographyScale.Large,
|
||||
},
|
||||
{
|
||||
label: t("home.settings.appearance.text_size_extra_large"),
|
||||
value: TVTypographyScale.ExtraLarge,
|
||||
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
|
||||
},
|
||||
],
|
||||
[t, currentTypographyScale],
|
||||
);
|
||||
|
||||
// Get display labels for option buttons
|
||||
const audioTranscodeLabel = useMemo(() => {
|
||||
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||
@@ -151,6 +186,11 @@ export default function SettingsTV() {
|
||||
return option?.label || "Bottom";
|
||||
}, [alignYOptions]);
|
||||
|
||||
const typographyScaleLabel = useMemo(() => {
|
||||
const option = typographyScaleOptions.find((o) => o.selected);
|
||||
return option?.label || t("home.settings.appearance.text_size_default");
|
||||
}, [typographyScaleOptions, t]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -166,7 +206,7 @@ export default function SettingsTV() {
|
||||
{/* Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 42,
|
||||
fontSize: typography.title,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
@@ -309,7 +349,7 @@ export default function SettingsTV() {
|
||||
<Text
|
||||
style={{
|
||||
color: "#9CA3AF",
|
||||
fontSize: 14,
|
||||
fontSize: typography.callout - 2,
|
||||
marginBottom: 16,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
@@ -333,7 +373,7 @@ export default function SettingsTV() {
|
||||
<Text
|
||||
style={{
|
||||
color: "#6B7280",
|
||||
fontSize: 12,
|
||||
fontSize: typography.callout - 4,
|
||||
marginTop: 8,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
@@ -344,6 +384,18 @@ export default function SettingsTV() {
|
||||
|
||||
{/* Appearance Section */}
|
||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
||||
<TVSettingsOptionButton
|
||||
label={t("home.settings.appearance.text_size")}
|
||||
value={typographyScaleLabel}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("home.settings.appearance.text_size"),
|
||||
options: typographyScaleOptions,
|
||||
onSelect: (value) =>
|
||||
updateSettings({ tvTypographyScale: value }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t(
|
||||
"home.settings.appearance.merge_next_up_continue_watching",
|
||||
@@ -358,6 +410,18 @@ export default function SettingsTV() {
|
||||
value={settings.showHomeBackdrop}
|
||||
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_hero_carousel")}
|
||||
value={settings.showTVHeroCarousel}
|
||||
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
|
||||
/>
|
||||
<TVSettingsToggle
|
||||
label={t("home.settings.appearance.show_series_poster_on_episode")}
|
||||
value={settings.showSeriesPosterOnEpisode}
|
||||
onToggle={(value) =>
|
||||
updateSettings({ showSeriesPosterOnEpisode: value })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User Section */}
|
||||
<TVSectionHeader
|
||||
|
||||
@@ -15,14 +15,29 @@ import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import {
|
||||
getItemNavigation,
|
||||
TouchableItemRouter,
|
||||
} from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import {
|
||||
TVFilterButton,
|
||||
TVFocusablePoster,
|
||||
TVItemCardText,
|
||||
} from "@/components/tv";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
@@ -36,6 +51,10 @@ import {
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
|
||||
const TV_ITEM_GAP = 16;
|
||||
const TV_SCALE_PADDING = 20;
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -44,11 +63,15 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const [orientation, _setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
@@ -56,7 +79,7 @@ const page: React.FC = () => {
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const { data: collection } = useQuery({
|
||||
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||
queryKey: ["collection", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return null;
|
||||
@@ -71,6 +94,46 @@ const page: React.FC = () => {
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// TV Filter queries
|
||||
const { data: tvGenreOptions } = useQuery({
|
||||
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Genres || [];
|
||||
},
|
||||
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||
});
|
||||
|
||||
const { data: tvYearOptions } = useQuery({
|
||||
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Years || [];
|
||||
},
|
||||
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||
});
|
||||
|
||||
const { data: tvTagOptions } = useQuery({
|
||||
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
|
||||
queryFn: async () => {
|
||||
if (!api) return [];
|
||||
const response = await getFilterApi(api).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
});
|
||||
return response.data.Tags || [];
|
||||
},
|
||||
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: collection?.Name || "" });
|
||||
setSortOrder([SortOrderOption.Ascending]);
|
||||
@@ -87,6 +150,18 @@ const page: React.FC = () => {
|
||||
setSortBy([sortByOption]);
|
||||
}, [navigation, collection]);
|
||||
|
||||
// Calculate columns for TV grid
|
||||
const nrOfCols = useMemo(() => {
|
||||
if (Platform.isTV) {
|
||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||
);
|
||||
}
|
||||
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
|
||||
}, [screenWidth, orientation]);
|
||||
|
||||
const fetchItems = useCallback(
|
||||
async ({
|
||||
pageParam,
|
||||
@@ -98,7 +173,7 @@ const page: React.FC = () => {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
limit: 18,
|
||||
limit: Platform.isTV ? 36 : 18,
|
||||
startIndex: pageParam,
|
||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||
sortBy: [sortBy[0]],
|
||||
@@ -123,6 +198,7 @@ const page: React.FC = () => {
|
||||
api,
|
||||
user?.Id,
|
||||
collection,
|
||||
collectionId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
@@ -131,39 +207,40 @@ const page: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collection,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"collection-items",
|
||||
collectionId,
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
queryFn: fetchItems,
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (
|
||||
!lastPage?.Items ||
|
||||
!lastPage?.TotalRecordCount ||
|
||||
lastPage?.TotalRecordCount === 0
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
return lastPage?.Items?.length * pages.length;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
},
|
||||
initialPageParam: 0,
|
||||
enabled: !!api && !!user?.Id && !!collection,
|
||||
});
|
||||
|
||||
const flatData = useMemo(() => {
|
||||
return (
|
||||
@@ -195,7 +272,6 @@ const page: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<ItemPoster item={item} />
|
||||
{/* <MoviePoster item={item} /> */}
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
@@ -203,9 +279,38 @@ const page: React.FC = () => {
|
||||
[orientation],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
const renderTVItem = useCallback(
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
const handlePress = () => {
|
||||
const navTarget = getItemNavigation(item, "(home)");
|
||||
router.push(navTarget as any);
|
||||
};
|
||||
|
||||
const _insets = useSafeAreaInsets();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
width: TV_POSTER_WIDTH,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster onPress={handlePress}>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
{item.Type !== "Movie" &&
|
||||
item.Type !== "Series" &&
|
||||
item.Type !== "Episode" && <MoviePoster item={item} />}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
@@ -372,48 +477,315 @@ const page: React.FC = () => {
|
||||
],
|
||||
);
|
||||
|
||||
// TV Filter options - with "All" option for clearable filters
|
||||
const tvGenreFilterOptions = useMemo(
|
||||
(): TVOptionItem<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, t],
|
||||
);
|
||||
|
||||
const tvYearFilterOptions = useMemo(
|
||||
(): TVOptionItem<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, t],
|
||||
);
|
||||
|
||||
const tvTagFilterOptions = useMemo(
|
||||
(): TVOptionItem<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, t],
|
||||
);
|
||||
|
||||
const tvSortByOptions = useMemo(
|
||||
(): TVOptionItem<SortByOption>[] =>
|
||||
sortOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortBy[0] === option.key,
|
||||
})),
|
||||
[sortBy],
|
||||
);
|
||||
|
||||
const tvSortOrderOptions = useMemo(
|
||||
(): TVOptionItem<SortOrderOption>[] =>
|
||||
sortOrderOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
selected: sortOrder[0] === option.key,
|
||||
})),
|
||||
[sortOrder],
|
||||
);
|
||||
|
||||
// TV Filter handlers using navigation-based modal
|
||||
const handleShowGenreFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.genres"),
|
||||
options: tvGenreFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||
|
||||
const handleShowYearFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.years"),
|
||||
options: tvYearFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||
|
||||
const handleShowTagFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.tags"),
|
||||
options: tvTagFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||
|
||||
const handleShowSortByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_by"),
|
||||
options: tvSortByOptions,
|
||||
onSelect: (value: SortByOption) => {
|
||||
setSortBy([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||
|
||||
const handleShowSortOrderFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_order"),
|
||||
options: tvSortOrderOptions,
|
||||
onSelect: (value: SortOrderOption) => {
|
||||
setSortOrder([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||
|
||||
// TV filter bar state
|
||||
const hasActiveFilters =
|
||||
selectedGenres.length > 0 ||
|
||||
selectedYears.length > 0 ||
|
||||
selectedTags.length > 0;
|
||||
|
||||
const resetAllFilters = useCallback(() => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedTags([]);
|
||||
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
|
||||
|
||||
if (isLoading || isCollectionLoading) {
|
||||
return (
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||
}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
// Mobile return
|
||||
if (!Platform.isTV) {
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
extraData={[
|
||||
selectedGenres,
|
||||
selectedYears,
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={nrOfCols}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
contentContainerStyle={{ paddingBottom: 24 }}
|
||||
ItemSeparatorComponent={() => (
|
||||
<View
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TV return with filter bar
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Filter bar */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "nowrap",
|
||||
marginTop: insets.top + 100,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: TV_SCALE_PADDING,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{hasActiveFilters && (
|
||||
<TVFilterButton
|
||||
label=''
|
||||
value={t("library.filters.reset")}
|
||||
onPress={resetAllFilters}
|
||||
hasActiveFilter
|
||||
/>
|
||||
)}
|
||||
<TVFilterButton
|
||||
label={t("library.filters.genres")}
|
||||
value={
|
||||
selectedGenres.length > 0
|
||||
? `${selectedGenres.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowGenreFilter}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.years")}
|
||||
value={
|
||||
selectedYears.length > 0
|
||||
? `${selectedYears.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowYearFilter}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.tags")}
|
||||
value={
|
||||
selectedTags.length > 0
|
||||
? `${selectedTags.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowTagFilter}
|
||||
hasActiveFilter={selectedTags.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_by")}
|
||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||
onPress={handleShowSortByFilter}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
value={
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={handleShowSortOrderFilter}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Grid */}
|
||||
<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("search.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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const page: React.FC = () => {
|
||||
});
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
@@ -117,7 +118,8 @@ const page: React.FC = () => {
|
||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||
),
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||
enabled: isOffline || (!!api && !!user?.Id),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
@@ -12,23 +11,13 @@ import {
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
FlatList,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
@@ -48,9 +37,15 @@ import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
import {
|
||||
TVFilterButton,
|
||||
TVFocusablePoster,
|
||||
TVItemCardText,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
@@ -74,280 +69,11 @@ import {
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
||||
|
||||
const TV_ITEM_GAP = 16;
|
||||
const TV_SCALE_PADDING = 20;
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// TV Filter Types and Components
|
||||
type TVFilterModalType =
|
||||
| "genre"
|
||||
| "year"
|
||||
| "tags"
|
||||
| "sortBy"
|
||||
| "sortOrder"
|
||||
| "filterBy"
|
||||
| null;
|
||||
|
||||
interface TVFilterOption<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const TVFilterOptionCard: React.FC<{
|
||||
label: string;
|
||||
selected: boolean;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
onPress: () => void;
|
||||
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.05);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: 160,
|
||||
height: 75,
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: selected
|
||||
? "rgba(255,255,255,0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || selected ? "600" : "400",
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{selected && !focused && (
|
||||
<View style={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={16}
|
||||
color='rgba(255,255,255,0.8)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const TVFilterButton: React.FC<{
|
||||
label: string;
|
||||
value: string;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
hasActiveFilter?: boolean;
|
||||
}> = ({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
hasTVPreferredFocus,
|
||||
disabled,
|
||||
hasActiveFilter,
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animateTo = (v: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: v,
|
||||
duration: 120,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
animateTo(1.04);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View style={{ transform: [{ scale }] }}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: hasActiveFilter
|
||||
? "rgba(255, 255, 255, 0.25)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
||||
borderColor: "rgba(255, 255, 255, 0.4)",
|
||||
}}
|
||||
>
|
||||
{label ? (
|
||||
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused ? "#000" : "#FFFFFF",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const TVFilterSelector = <T,>({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: TVFilterOption<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
// Track initial focus index - only set once when modal opens
|
||||
const initialFocusIndexRef = useRef<number | null>(null);
|
||||
|
||||
// Calculate initial focus index only once when visible becomes true
|
||||
if (visible && initialFocusIndexRef.current === null) {
|
||||
const idx = options.findIndex((o) => o.selected);
|
||||
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
// Reset when modal closes
|
||||
if (!visible) {
|
||||
initialFocusIndexRef.current = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<BlurView
|
||||
intensity={80}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingVertical: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
paddingHorizontal: 48,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<TVFilterOptionCard
|
||||
key={String(option.value)}
|
||||
label={option.label}
|
||||
selected={option.selected}
|
||||
hasTVPreferredFocus={index === initialFocusIndex}
|
||||
onPress={() => {
|
||||
onSelect(option.value);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const TV_ITEM_GAP = 20;
|
||||
const TV_HORIZONTAL_PADDING = 60;
|
||||
const _TV_SCALE_PADDING = 20;
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams() as {
|
||||
@@ -358,6 +84,7 @@ const Page = () => {
|
||||
};
|
||||
const { libraryId } = searchParams;
|
||||
|
||||
const typography = useScaledTVTypography();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
@@ -380,13 +107,7 @@ const Page = () => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
// TV Filter modal state
|
||||
const [openFilterModal, setOpenFilterModal] =
|
||||
useState<TVFilterModalType>(null);
|
||||
const isFilterModalOpen = openFilterModal !== null;
|
||||
|
||||
const isFiltersDisabled = isFilterModalOpen;
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
// TV Filter queries
|
||||
const { data: tvGenreOptions } = useQuery({
|
||||
@@ -511,12 +232,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;
|
||||
@@ -682,7 +399,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const renderTVItem = useCallback(
|
||||
({ item }: { item: BaseItemDto }) => {
|
||||
(item: BaseItemDto) => {
|
||||
const handlePress = () => {
|
||||
const navTarget = getItemNavigation(item, "(libraries)");
|
||||
router.push(navTarget as any);
|
||||
@@ -690,13 +407,12 @@ const Page = () => {
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
width: TV_POSTER_WIDTH,
|
||||
}}
|
||||
>
|
||||
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
|
||||
<TVFocusablePoster onPress={handlePress}>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||
<SeriesPoster item={item} />
|
||||
@@ -709,7 +425,7 @@ const Page = () => {
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[router, isFilterModalOpen],
|
||||
[router],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
@@ -912,7 +628,7 @@ const Page = () => {
|
||||
|
||||
// TV Filter options - with "All" option for clearable filters
|
||||
const tvGenreFilterOptions = useMemo(
|
||||
(): TVFilterOption<string>[] => [
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
@@ -928,7 +644,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const tvYearFilterOptions = useMemo(
|
||||
(): TVFilterOption<string>[] => [
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
@@ -944,7 +660,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const tvTagFilterOptions = useMemo(
|
||||
(): TVFilterOption<string>[] => [
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
@@ -960,7 +676,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const tvSortByOptions = useMemo(
|
||||
(): TVFilterOption<SortByOption>[] =>
|
||||
(): TVOptionItem<SortByOption>[] =>
|
||||
sortOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
@@ -970,7 +686,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const tvSortOrderOptions = useMemo(
|
||||
(): TVFilterOption<SortOrderOption>[] =>
|
||||
(): TVOptionItem<SortOrderOption>[] =>
|
||||
sortOrderOptions.map((option) => ({
|
||||
label: option.value,
|
||||
value: option.key,
|
||||
@@ -980,7 +696,7 @@ const Page = () => {
|
||||
);
|
||||
|
||||
const tvFilterByOptions = useMemo(
|
||||
(): TVFilterOption<string>[] => [
|
||||
(): TVOptionItem<string>[] => [
|
||||
{
|
||||
label: t("library.filters.all"),
|
||||
value: "__all__",
|
||||
@@ -995,56 +711,88 @@ const Page = () => {
|
||||
[filterBy, generalFilters, t],
|
||||
);
|
||||
|
||||
// TV Filter handlers
|
||||
const handleGenreSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
[selectedGenres, setSelectedGenres],
|
||||
);
|
||||
// TV Filter handlers using navigation-based modal
|
||||
const handleShowGenreFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.genres"),
|
||||
options: tvGenreFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedGenres([]);
|
||||
} else if (selectedGenres.includes(value)) {
|
||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||
} else {
|
||||
setSelectedGenres([...selectedGenres, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||
|
||||
const handleYearSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
[selectedYears, setSelectedYears],
|
||||
);
|
||||
const handleShowYearFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.years"),
|
||||
options: tvYearFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedYears([]);
|
||||
} else if (selectedYears.includes(value)) {
|
||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||
} else {
|
||||
setSelectedYears([...selectedYears, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||
|
||||
const handleTagSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((t) => t !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
[selectedTags, setSelectedTags],
|
||||
);
|
||||
const handleShowTagFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.tags"),
|
||||
options: tvTagFilterOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
setSelectedTags([]);
|
||||
} else if (selectedTags.includes(value)) {
|
||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||
} else {
|
||||
setSelectedTags([...selectedTags, value]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||
|
||||
const handleFilterBySelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === "__all__") {
|
||||
_setFilterBy([]);
|
||||
} else {
|
||||
setFilter([value as FilterByOption]);
|
||||
}
|
||||
},
|
||||
[setFilter, _setFilterBy],
|
||||
);
|
||||
const handleShowSortByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_by"),
|
||||
options: tvSortByOptions,
|
||||
onSelect: (value: SortByOption) => {
|
||||
setSortBy([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||
|
||||
const handleShowSortOrderFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.sort_order"),
|
||||
options: tvSortOrderOptions,
|
||||
onSelect: (value: SortOrderOption) => {
|
||||
setSortOrder([value]);
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||
|
||||
const handleShowFilterByFilter = useCallback(() => {
|
||||
showOptions({
|
||||
title: t("library.filters.filter_by"),
|
||||
options: tvFilterByOptions,
|
||||
onSelect: (value: string) => {
|
||||
if (value === "__all__") {
|
||||
_setFilterBy([]);
|
||||
} else {
|
||||
setFilter([value as FilterByOption]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -1097,185 +845,134 @@ const Page = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
|
||||
// TV return with filter bar
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Background content - disabled when modal is open */}
|
||||
<View
|
||||
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
|
||||
focusable={!isFilterModalOpen}
|
||||
isTVSelectable={!isFilterModalOpen}
|
||||
pointerEvents={isFilterModalOpen ? "none" : "auto"}
|
||||
accessibilityElementsHidden={isFilterModalOpen}
|
||||
importantForAccessibility={
|
||||
isFilterModalOpen ? "no-hide-descendants" : "auto"
|
||||
<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",
|
||||
justifyContent: "center",
|
||||
paddingBottom: 24,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "nowrap",
|
||||
marginTop: insets.top + 100,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: TV_SCALE_PADDING,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{hasActiveFilters && (
|
||||
<TVFilterButton
|
||||
label=''
|
||||
value={t("library.filters.reset")}
|
||||
onPress={resetAllFilters}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter
|
||||
/>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<TVFilterButton
|
||||
label={t("library.filters.genres")}
|
||||
value={
|
||||
selectedGenres.length > 0
|
||||
? `${selectedGenres.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("genre")}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
label=''
|
||||
value={t("library.filters.reset")}
|
||||
onPress={resetAllFilters}
|
||||
hasActiveFilter
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.years")}
|
||||
value={
|
||||
selectedYears.length > 0
|
||||
? `${selectedYears.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("year")}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.tags")}
|
||||
value={
|
||||
selectedTags.length > 0
|
||||
? `${selectedTags.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("tags")}
|
||||
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={isFiltersDisabled}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
value={
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={() => setOpenFilterModal("sortOrder")}
|
||||
disabled={isFiltersDisabled}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.filter_by")}
|
||||
value={
|
||||
filterBy.length > 0
|
||||
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={() => setOpenFilterModal("filterBy")}
|
||||
disabled={isFiltersDisabled}
|
||||
hasActiveFilter={filterBy.length > 0}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<TVFilterButton
|
||||
label={t("library.filters.genres")}
|
||||
value={
|
||||
selectedGenres.length > 0
|
||||
? `${selectedGenres.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderTVItem}
|
||||
extraData={[orientation, nrOfCols, isFilterModalOpen]}
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onPress={handleShowGenreFilter}
|
||||
hasTVPreferredFocus={!hasActiveFilters}
|
||||
hasActiveFilter={selectedGenres.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.years")}
|
||||
value={
|
||||
selectedYears.length > 0
|
||||
? `${selectedYears.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowYearFilter}
|
||||
hasActiveFilter={selectedYears.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.tags")}
|
||||
value={
|
||||
selectedTags.length > 0
|
||||
? `${selectedTags.length} selected`
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowTagFilter}
|
||||
hasActiveFilter={selectedTags.length > 0}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_by")}
|
||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||
onPress={handleShowSortByFilter}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.sort_order")}
|
||||
value={
|
||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||
}
|
||||
onPress={handleShowSortOrderFilter}
|
||||
/>
|
||||
<TVFilterButton
|
||||
label={t("library.filters.filter_by")}
|
||||
value={
|
||||
filterBy.length > 0
|
||||
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
||||
: t("library.filters.all")
|
||||
}
|
||||
onPress={handleShowFilterByFilter}
|
||||
hasActiveFilter={filterBy.length > 0}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* TV Filter Overlays */}
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "genre"}
|
||||
title={t("library.filters.genres")}
|
||||
options={tvGenreFilterOptions}
|
||||
onSelect={handleGenreSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "year"}
|
||||
title={t("library.filters.years")}
|
||||
options={tvYearFilterOptions}
|
||||
onSelect={handleYearSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "tags"}
|
||||
title={t("library.filters.tags")}
|
||||
options={tvTagFilterOptions}
|
||||
onSelect={handleTagSelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "sortBy"}
|
||||
title={t("library.filters.sort_by")}
|
||||
options={tvSortByOptions}
|
||||
onSelect={(value) => setSortBy([value])}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "sortOrder"}
|
||||
title={t("library.filters.sort_order")}
|
||||
options={tvSortOrderOptions}
|
||||
onSelect={(value) => setSortOrder([value])}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
<TVFilterSelector
|
||||
visible={openFilterModal === "filterBy"}
|
||||
title={t("library.filters.filter_by")}
|
||||
options={tvFilterByOptions}
|
||||
onSelect={handleFilterBySelect}
|
||||
onClose={() => setOpenFilterModal(null)}
|
||||
/>
|
||||
</View>
|
||||
{/* Grid with flexWrap */}
|
||||
{flatData.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingTop: 100,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: typography.body, 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import {
|
||||
@@ -41,21 +43,36 @@ 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 }) => (
|
||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||
|
||||
const TVItemCardText: React.FC<{
|
||||
item: BaseItemDto;
|
||||
typography: Typography;
|
||||
}> = ({ item, typography }) => (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout - 2,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function WatchlistDetailScreen() {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
@@ -70,14 +87,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 +196,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 +204,8 @@ export default function WatchlistDetailScreen() {
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
marginRight: TV_ITEM_GAP,
|
||||
marginBottom: TV_ITEM_GAP,
|
||||
width: TV_POSTER_WIDTH,
|
||||
}}
|
||||
>
|
||||
@@ -208,11 +218,11 @@ export default function WatchlistDetailScreen() {
|
||||
<SeriesPoster item={item} />
|
||||
)}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
<TVItemCardText item={item} typography={typography} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[router],
|
||||
[router, typography],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -328,6 +338,120 @@ 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: typography.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: typography.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: typography.callout, color: "#9CA3AF" }}>
|
||||
{watchlist.isPublic
|
||||
? t("watchlists.public")
|
||||
: t("watchlists.private")}
|
||||
</Text>
|
||||
</View>
|
||||
{!isOwner && (
|
||||
<Text style={{ fontSize: typography.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: typography.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={{
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRo
|
||||
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||
@@ -30,6 +30,7 @@ import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVRequestModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvRequestModalAtom);
|
||||
const { t } = useTranslation();
|
||||
@@ -336,8 +337,12 @@ export default function TVRequestModalPage() {
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
|
||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
||||
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||
{t("jellyseerr.advanced")}
|
||||
</Text>
|
||||
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||
{modalState.title}
|
||||
</Text>
|
||||
|
||||
{isDataLoaded && isReady ? (
|
||||
<ScrollView
|
||||
@@ -390,7 +395,12 @@ export default function TVRequestModalPage() {
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.buttonText}>
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
{ fontSize: typography.callout },
|
||||
]}
|
||||
>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Text>
|
||||
</TVButton>
|
||||
@@ -451,13 +461,11 @@ const styles = StyleSheet.create({
|
||||
overflow: "visible",
|
||||
},
|
||||
heading: {
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 24,
|
||||
},
|
||||
@@ -482,7 +490,6 @@ const styles = StyleSheet.create({
|
||||
marginTop: 24,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVButton } from "@/components/tv";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||
@@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||
};
|
||||
|
||||
export default function TVSeasonSelectModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||
const { t } = useTranslation();
|
||||
@@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() {
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
|
||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
||||
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||
{t("jellyseerr.select_seasons")}
|
||||
</Text>
|
||||
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||
{modalState.title}
|
||||
</Text>
|
||||
|
||||
{/* Season cards horizontal scroll */}
|
||||
<ScrollView
|
||||
@@ -343,7 +348,9 @@ export default function TVSeasonSelectModalPage() {
|
||||
color='#FFFFFF'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.buttonText}>
|
||||
<Text
|
||||
style={[styles.buttonText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("jellyseerr.request_selected")}
|
||||
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||
</Text>
|
||||
@@ -377,13 +384,11 @@ const styles = StyleSheet.create({
|
||||
overflow: "visible",
|
||||
},
|
||||
heading: {
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 24,
|
||||
},
|
||||
@@ -413,7 +418,6 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
seasonTitle: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
@@ -436,7 +440,6 @@ const styles = StyleSheet.create({
|
||||
marginTop: 24,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
export default function TVSeriesSeasonModalPage() {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||
const { t } = useTranslation();
|
||||
@@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() {
|
||||
trapFocusRight
|
||||
style={styles.content}
|
||||
>
|
||||
<Text style={styles.title}>{t("item_card.select_season")}</Text>
|
||||
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||
{t("item_card.select_season")}
|
||||
</Text>
|
||||
|
||||
{isReady && (
|
||||
<ScrollView
|
||||
@@ -164,7 +167,6 @@ const styles = StyleSheet.create({
|
||||
overflow: "visible",
|
||||
},
|
||||
title: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
|
||||
110
bun.lock
110
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "streamyfin",
|
||||
@@ -77,7 +78,6 @@
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-responsive-sizes": "^2.1.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
@@ -562,8 +562,6 @@
|
||||
|
||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
||||
|
||||
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.79.7", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-CPJ995n1WIyi7KeLj+/aeFCe6MWQrRRXfMvBnc7XP4noSa4WEJfH8Zcvl/iWYVxrQdIaInadoiYLakeSflz5jg=="],
|
||||
|
||||
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
|
||||
|
||||
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
|
||||
@@ -804,10 +802,6 @@
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="],
|
||||
|
||||
"caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
@@ -1246,8 +1240,6 @@
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="],
|
||||
|
||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
@@ -1326,8 +1318,6 @@
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
@@ -1696,8 +1686,6 @@
|
||||
|
||||
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
|
||||
|
||||
"react-native-responsive-sizes": ["react-native-responsive-sizes@2.1.0", "", { "dependencies": { "react-native": "^0.79.2" } }, "sha512-uxWi0IDj8CBGRh6KJyQ2RagWmLTWPWF5sDnVpM4jt/khwhEdaUeGa/q9rHcVHbb4o+oo1Zei9P3zIwbFc1UGcw=="],
|
||||
|
||||
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
|
||||
|
||||
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
|
||||
@@ -2292,8 +2280,6 @@
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="],
|
||||
|
||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -2446,8 +2432,6 @@
|
||||
|
||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native": ["react-native@0.79.7", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.7", "@react-native/codegen": "0.79.7", "@react-native/community-cli-plugin": "0.79.7", "@react-native/gradle-plugin": "0.79.7", "@react-native/js-polyfills": "0.79.7", "@react-native/normalize-colors": "0.79.7", "@react-native/virtualized-lists": "0.79.7", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-7B2FJt/P+qulrkjWNttofiQjpZ5czSnL00kr6kQ9GpiykF/agX6Z2GVX6e5ggpQq2jqtyLvRtHIiUnKPYM77+w=="],
|
||||
|
||||
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
|
||||
|
||||
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
||||
@@ -2994,30 +2978,6 @@
|
||||
|
||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/assets-registry": ["@react-native/assets-registry@0.79.7", "", {}, "sha512-YeOXq8H5JZQbeIcAtHxmboDt02QG8ej8Z4SFVNh5UjaSb/0X1/v5/DhwNb4dfpIsQ5lFy75jeoSmUVp8qEKu9g=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/codegen": ["@react-native/codegen@0.79.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-uOjsqpLccl0+8iHPBmrkFrWwK0ctW28M83Ln2z43HRNubkxk5Nxd3DoyphFPL/BwTG79Ixu+BqpCS7b9mtizpw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.79.7", "", { "dependencies": { "@react-native/dev-middleware": "0.79.7", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.82.0", "metro-config": "^0.82.0", "metro-core": "^0.82.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-UQADqWfnKfEGMIyOa1zI8TMAOOLDdQ3h2FTCG8bp+MFGLAaJowaa+4GGb71A26fbg06/qnGy/Kr0Mv41IFGZnQ=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.79.7", "", {}, "sha512-vQqVthSs2EGqzV4KI0uFr/B4hUVXhVM86ekYL8iZCXzO6bewZa7lEUNGieijY0jc0a/mBJ6KZDzMtcUoS5vFRA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/js-polyfills": ["@react-native/js-polyfills@0.79.7", "", {}, "sha512-Djgvfz6AOa8ZEWyv+KA/UnP+ZruM+clCauFTR6NeRyD8YELvXGt+6A231SwpNdRkM7aTDMv0cM0NUbAMEPy+1A=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.7", "", {}, "sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.25.1", "", { "dependencies": { "hermes-parser": "0.25.1" } }, "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/metro-runtime": ["metro-runtime@0.82.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/metro-source-map": ["metro-source-map@0.82.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.82.5", "nullthrows": "^1.1.1", "ob1": "0.82.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
@@ -3202,26 +3162,6 @@
|
||||
|
||||
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.7", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.7", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-KHGPa7xwnKKWrzMnV1cHc8J56co4tFevmRvbjEbUCqkGS0s/l8ZxAGMR222/6YxZV3Eg1J3ywKQ8nHzTsTz5jw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro": ["metro@0.82.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-config": "0.82.5", "metro-core": "0.82.5", "metro-file-map": "0.82.5", "metro-resolver": "0.82.5", "metro-runtime": "0.82.5", "metro-source-map": "0.82.5", "metro-symbolicate": "0.82.5", "metro-transform-plugins": "0.82.5", "metro-transform-worker": "0.82.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config": ["metro-config@0.82.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.82.5", "metro-cache": "0.82.5", "metro-core": "0.82.5", "metro-runtime": "0.82.5" } }, "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core": ["metro-core@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.82.5" } }, "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/metro-source-map/@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/metro-source-map/ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="],
|
||||
|
||||
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
@@ -3258,42 +3198,6 @@
|
||||
|
||||
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.7", "", {}, "sha512-91JVlhR6hDuJXcWTpCwcdEPlUQf+TckNG8BYfR4UkUOaZ87XahJv4EyWBeyfd8lwB/mh6nDJqbR6UiXwt5kbog=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.82.5", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.82.5", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-minify-terser": "0.82.5", "metro-source-map": "0.82.5", "metro-transform-plugins": "0.82.5", "nullthrows": "^1.1.1" } }, "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
|
||||
|
||||
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
|
||||
@@ -3306,18 +3210,6 @@
|
||||
|
||||
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg=="],
|
||||
|
||||
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="],
|
||||
|
||||
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -16,6 +16,8 @@ export const Badge: React.FC<Props> = ({
|
||||
variant = "purple",
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||
@@ -69,7 +71,7 @@ export const Badge: React.FC<Props> = ({
|
||||
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#E5E7EB",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { fontSize, size } from "react-native-responsive-sizes";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
@@ -141,15 +140,11 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={`rounded-2xl items-center justify-center
|
||||
className={`rounded-2xl py-5 items-center justify-center
|
||||
${colorClasses}
|
||||
${className}`}
|
||||
style={{ paddingVertical: size(50) }}
|
||||
>
|
||||
<Text
|
||||
className={`${textColorClass} font-bold`}
|
||||
style={{ fontSize: fontSize(10) }}
|
||||
>
|
||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -5,11 +5,15 @@ import { useAtomValue } from "jotai";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ProgressBar } from "./common/ProgressBar";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
|
||||
export const TV_LANDSCAPE_WIDTH = 340;
|
||||
export const TV_LANDSCAPE_WIDTH = 400;
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -32,34 +36,57 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
return;
|
||||
}
|
||||
if (item.Type === "Episode" && useEpisodePoster) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}
|
||||
if (item.Type === "Movie") {
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
}
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}
|
||||
if (item.Type === "Program") {
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
}
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}
|
||||
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||
}
|
||||
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}, [api, item, useEpisodePoster]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (item.Type === "Program") {
|
||||
if (!item.StartDate || !item.EndDate) {
|
||||
return 0;
|
||||
}
|
||||
const startDate = new Date(item.StartDate);
|
||||
const endDate = new Date(item.EndDate);
|
||||
const now = new Date();
|
||||
const total = endDate.getTime() - startDate.getTime();
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const elapsed = now.getTime() - startDate.getTime();
|
||||
return (elapsed / total) * 100;
|
||||
}
|
||||
return item.UserData?.PlayedPercentage || 0;
|
||||
}, [item]);
|
||||
|
||||
const isWatched = item.UserData?.Played === true;
|
||||
|
||||
// Use glass effect on tvOS 26+
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<View
|
||||
@@ -72,6 +99,39 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (useGlass) {
|
||||
return (
|
||||
<View style={{ position: "relative" }}>
|
||||
<GlassPosterView
|
||||
imageUrl={url}
|
||||
aspectRatio={16 / 9}
|
||||
cornerRadius={24}
|
||||
progress={progress}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={false}
|
||||
width={TV_LANDSCAPE_WIDTH}
|
||||
style={{ width: TV_LANDSCAPE_WIDTH }}
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='play-circle' size={56} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
@@ -25,6 +25,9 @@ export const Tag: React.FC<
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
} & ViewProps
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
// Hook must be called at the top level, before any conditional returns
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||
return (
|
||||
<View>
|
||||
@@ -60,7 +63,7 @@ export const Tag: React.FC<
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
|
||||
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {
|
||||
MediaSourceInfo,
|
||||
MediaStream,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -17,12 +17,14 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
|
||||
import { Dimensions, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
||||
import {
|
||||
TVBackdrop,
|
||||
TVButton,
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
TVTechnicalDetails,
|
||||
} from "@/components/tv";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
@@ -69,8 +71,9 @@ interface ItemContentTVProps {
|
||||
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
||||
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
({ item, itemWithSources }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [_user] = useAtom(userAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -80,6 +83,31 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
|
||||
const _itemColors = useImageColorsReturn({ item });
|
||||
|
||||
// State for first episode card ref (used for focus guide)
|
||||
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
|
||||
|
||||
// Fetch season episodes for episodes
|
||||
const { data: seasonEpisodes = [] } = useQuery({
|
||||
queryKey: ["episodes", item?.SeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId,
|
||||
userId: user.Id,
|
||||
seasonId: item.SeasonId,
|
||||
enableUserData: true,
|
||||
fields: ["MediaSources", "Overview"],
|
||||
});
|
||||
return res.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
!!api &&
|
||||
!!user?.Id &&
|
||||
!!item?.SeriesId &&
|
||||
!!item?.SeasonId &&
|
||||
item?.Type === "Episode",
|
||||
});
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
@@ -135,14 +163,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
const { showSubtitleModal } = useTVSubtitleModal();
|
||||
|
||||
// State for first actor card ref (used for focus guide)
|
||||
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// State for last option button ref (used for upward focus guide from cast)
|
||||
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
|
||||
null,
|
||||
);
|
||||
const [_lastOptionButtonRef, setLastOptionButtonRef] =
|
||||
useState<View | null>(null);
|
||||
|
||||
// Get available audio tracks
|
||||
const audioTracks = useMemo(() => {
|
||||
@@ -386,6 +413,18 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
||||
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
||||
|
||||
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
|
||||
const episodeThumbnailUrl = useMemo(() => {
|
||||
if (item?.Type !== "Episode" || !api) return null;
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||
}, [api, item]);
|
||||
|
||||
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
|
||||
const seriesThumbUrl = useMemo(() => {
|
||||
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
|
||||
}, [api, item]);
|
||||
|
||||
// Determine which option button is the last one (for focus guide targeting)
|
||||
const lastOptionButton = useMemo(() => {
|
||||
const hasSubtitleOption =
|
||||
@@ -427,6 +466,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
}
|
||||
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
|
||||
|
||||
const handleEpisodePress = useCallback(
|
||||
(episode: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(episode, "(home)");
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
if (!item || !selectedOptions) return null;
|
||||
|
||||
return (
|
||||
@@ -456,36 +503,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
minHeight: SCREEN_HEIGHT * 0.45,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Poster */}
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH * 0.22,
|
||||
marginRight: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
}}
|
||||
>
|
||||
<ItemImage
|
||||
variant='Primary'
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Right side - Content */}
|
||||
{/* Left side - Content */}
|
||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||
{/* Logo or Title */}
|
||||
{logoUrl ? (
|
||||
@@ -502,7 +520,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.display,
|
||||
fontSize: typography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 20,
|
||||
@@ -518,7 +536,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.title,
|
||||
fontSize: typography.title,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
@@ -527,7 +545,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "white",
|
||||
marginTop: 6,
|
||||
}}
|
||||
@@ -572,7 +590,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#E5E7EB",
|
||||
lineHeight: 32,
|
||||
}}
|
||||
@@ -605,7 +623,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
}}
|
||||
@@ -714,14 +732,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Focus guide to direct navigation from options to cast list */}
|
||||
{fullCast.length > 0 && firstActorCardRef && (
|
||||
<TVFocusGuideView
|
||||
destinations={[firstActorCardRef]}
|
||||
style={{ height: 1, width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress bar (if partially watched) */}
|
||||
{hasProgress && item.RunTimeTicks != null && (
|
||||
<TVProgressBar
|
||||
@@ -733,11 +743,112 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Poster */}
|
||||
<View
|
||||
style={{
|
||||
width:
|
||||
item.Type === "Episode"
|
||||
? SCREEN_WIDTH * 0.35
|
||||
: SCREEN_WIDTH * 0.22,
|
||||
marginLeft: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
}}
|
||||
>
|
||||
{item.Type === "Episode" ? (
|
||||
<Image
|
||||
source={{
|
||||
uri:
|
||||
settings.showSeriesPosterOnEpisode && seriesThumbUrl
|
||||
? seriesThumbUrl
|
||||
: episodeThumbnailUrl!,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
variant='Primary'
|
||||
item={item}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Additional info section */}
|
||||
<View style={{ marginTop: 40 }}>
|
||||
{/* Cast & Crew (text version) */}
|
||||
{/* Season Episodes - Episode only */}
|
||||
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
|
||||
<View style={{ marginBottom: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{t("item_card.more_from_this_season")}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ marginHorizontal: -80, overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 80,
|
||||
paddingVertical: 12,
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{seasonEpisodes.map((episode, index) => (
|
||||
<TVEpisodeCard
|
||||
key={episode.Id}
|
||||
episode={episode}
|
||||
onPress={() => handleEpisodePress(episode)}
|
||||
disabled={episode.Id === item.Id}
|
||||
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* From this Series - Episode only */}
|
||||
<TVSeriesNavigation
|
||||
item={item}
|
||||
seriesImageUrl={seriesImageUrl}
|
||||
seasonImageUrl={seasonImageUrl}
|
||||
onSeriesPress={handleSeriesPress}
|
||||
onSeasonPress={handleSeasonPress}
|
||||
/>
|
||||
|
||||
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||
{showVisualCast && (
|
||||
<TVCastSection
|
||||
cast={fullCast}
|
||||
apiBasePath={api?.basePath}
|
||||
onActorPress={handleActorPress}
|
||||
firstActorRefSetter={setFirstActorCardRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Cast & Crew (text version - director, etc.) */}
|
||||
<TVCastCrewText
|
||||
director={director}
|
||||
cast={cast}
|
||||
@@ -751,26 +862,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||
{showVisualCast && (
|
||||
<TVCastSection
|
||||
cast={fullCast}
|
||||
apiBasePath={api?.basePath}
|
||||
onActorPress={handleActorPress}
|
||||
firstActorRefSetter={setFirstActorCardRef}
|
||||
upwardFocusDestination={lastOptionButtonRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* From this Series - Episode only */}
|
||||
<TVSeriesNavigation
|
||||
item={item}
|
||||
seriesImageUrl={seriesImageUrl}
|
||||
seasonImageUrl={seasonImageUrl}
|
||||
onSeriesPress={handleSeriesPress}
|
||||
onSeasonPress={handleSeasonPress}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -1,41 +1,28 @@
|
||||
import React from "react";
|
||||
import { Dimensions, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
|
||||
export const ItemContentSkeletonTV: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
paddingTop: 180,
|
||||
paddingHorizontal: 160,
|
||||
paddingTop: insets.top + 140,
|
||||
paddingHorizontal: insets.left + 80,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Poster placeholder */}
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH * 0.22,
|
||||
marginRight: 50,
|
||||
}}
|
||||
>
|
||||
{/* Left side - Content placeholders */}
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Logo placeholder */}
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Right side - Content placeholders */}
|
||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||
{/* Logo/Title placeholder */}
|
||||
<View
|
||||
style={{
|
||||
height: 80,
|
||||
width: "60%",
|
||||
height: 150,
|
||||
width: "80%",
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: 8,
|
||||
marginBottom: 24,
|
||||
@@ -155,6 +142,22 @@ export const ItemContentSkeletonTV: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Right side - Poster placeholder */}
|
||||
<View
|
||||
style={{
|
||||
width: SCREEN_WIDTH * 0.22,
|
||||
marginLeft: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,909 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { ItemImage } from "../common/ItemImage";
|
||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
||||
import type { SelectedOptions } from "../ItemContent";
|
||||
import { PlayButton } from "../PlayButton";
|
||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
||||
|
||||
interface AppleTVCarouselProps {
|
||||
initialIndex?: number;
|
||||
onItemChange?: (index: number) => void;
|
||||
scrollOffset?: SharedValue<number>;
|
||||
}
|
||||
|
||||
// Layout Constants
|
||||
const GRADIENT_HEIGHT_TOP = 150;
|
||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
||||
const LOGO_HEIGHT = 80;
|
||||
|
||||
// Position Constants
|
||||
const LOGO_BOTTOM_POSITION = 260;
|
||||
const GENRES_BOTTOM_POSITION = 220;
|
||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
||||
const CONTROLS_BOTTOM_POSITION = 80;
|
||||
const DOTS_BOTTOM_POSITION = 40;
|
||||
|
||||
// Size Constants
|
||||
const DOT_HEIGHT = 6;
|
||||
const DOT_ACTIVE_WIDTH = 20;
|
||||
const DOT_INACTIVE_WIDTH = 12;
|
||||
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
||||
const TEXT_SKELETON_HEIGHT = 20;
|
||||
const TEXT_SKELETON_WIDTH = 250;
|
||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
||||
|
||||
// Spacing Constants
|
||||
const HORIZONTAL_PADDING = 40;
|
||||
const DOT_PADDING = 2;
|
||||
const DOT_GAP = 4;
|
||||
const CONTROLS_GAP = 10;
|
||||
const _TEXT_MARGIN_TOP = 16;
|
||||
|
||||
// Border Radius Constants
|
||||
const DOT_BORDER_RADIUS = 3;
|
||||
const LOGO_SKELETON_BORDER_RADIUS = 8;
|
||||
const TEXT_SKELETON_BORDER_RADIUS = 4;
|
||||
const PLAY_BUTTON_BORDER_RADIUS = 25;
|
||||
const PLAYED_STATUS_BORDER_RADIUS = 20;
|
||||
|
||||
// Animation Constants
|
||||
const DOT_ANIMATION_DURATION = 300;
|
||||
const CAROUSEL_TRANSITION_DURATION = 250;
|
||||
const PAN_ACTIVE_OFFSET = 10;
|
||||
const TRANSLATION_THRESHOLD = 0.2;
|
||||
const VELOCITY_THRESHOLD = 400;
|
||||
|
||||
// Text Constants
|
||||
const GENRES_FONT_SIZE = 16;
|
||||
const OVERVIEW_FONT_SIZE = 14;
|
||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
||||
const TEXT_SHADOW_RADIUS = 2;
|
||||
const MAX_GENRES_COUNT = 2;
|
||||
const MAX_BUTTON_WIDTH = 300;
|
||||
const OVERVIEW_MAX_LINES = 2;
|
||||
const OVERVIEW_MAX_WIDTH = "80%";
|
||||
|
||||
// Opacity Constants
|
||||
const OVERLAY_OPACITY = 0.3;
|
||||
const DOT_INACTIVE_OPACITY = 0.6;
|
||||
const TEXT_OPACITY = 0.9;
|
||||
|
||||
// Color Constants
|
||||
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
|
||||
const SKELETON_ELEMENT_COLOR = "#333";
|
||||
const SKELETON_ACTIVE_DOT_COLOR = "#666";
|
||||
const _EMPTY_STATE_COLOR = "#666";
|
||||
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
|
||||
const LOGO_WIDTH_PERCENTAGE = "80%";
|
||||
|
||||
const DotIndicator = ({
|
||||
index,
|
||||
currentIndex,
|
||||
onPress,
|
||||
}: {
|
||||
index: number;
|
||||
currentIndex: number;
|
||||
onPress: (index: number) => void;
|
||||
}) => {
|
||||
const isActive = index === currentIndex;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
|
||||
duration: DOT_ANIMATION_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
}),
|
||||
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
|
||||
duration: DOT_ANIMATION_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onPress(index)}
|
||||
style={{
|
||||
padding: DOT_PADDING, // Increase touch area
|
||||
}}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: DOT_HEIGHT,
|
||||
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
||||
borderRadius: DOT_BORDER_RADIUS,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
initialIndex = 0,
|
||||
onItemChange,
|
||||
scrollOffset,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
const router = useRouter();
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
const isLandscape = screenWidth >= screenHeight;
|
||||
const carouselHeight = useMemo(
|
||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
||||
[isLandscape, screenHeight],
|
||||
);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
||||
|
||||
const isQueryEnabled =
|
||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
||||
|
||||
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
|
||||
useQuery({
|
||||
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres", "Overview"],
|
||||
limit: 2,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isQueryEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
|
||||
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user.Id,
|
||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
||||
limit: 2,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: isQueryEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
|
||||
{
|
||||
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user.Id,
|
||||
limit: 2,
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
});
|
||||
return response.data || [];
|
||||
},
|
||||
enabled: isQueryEnabled,
|
||||
staleTime: 60 * 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const continueItems = continueWatchingData ?? [];
|
||||
const nextItems = nextUpData ?? [];
|
||||
const recentItems = recentlyAddedData ?? [];
|
||||
|
||||
const allItems = [
|
||||
...continueItems.slice(0, 2),
|
||||
...nextItems.slice(0, 2),
|
||||
...recentItems.slice(0, 2),
|
||||
];
|
||||
|
||||
// Deduplicate by item ID to prevent duplicate keys
|
||||
const seen = new Set<string>();
|
||||
return allItems.filter((item) => {
|
||||
if (item.Id && !seen.has(item.Id)) {
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
||||
|
||||
const isLoading =
|
||||
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
|
||||
const hasItems = items.length > 0;
|
||||
|
||||
// Only get play settings if we have valid items
|
||||
const currentItem = hasItems ? items[currentIndex] : null;
|
||||
|
||||
// Extract colors for the current item only (for performance)
|
||||
const currentItemColors = useImageColorsReturn({ item: currentItem });
|
||||
|
||||
// Create a fallback empty item for useDefaultPlaySettings when no item is available
|
||||
const itemForPlaySettings = currentItem || { MediaSources: [] };
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// Only set options if we have valid current item
|
||||
if (currentItem) {
|
||||
setSelectedOptions({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
});
|
||||
} else {
|
||||
setSelectedOptions(undefined);
|
||||
}
|
||||
}, [
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultSubtitleIndex,
|
||||
defaultMediaSource,
|
||||
currentIndex,
|
||||
currentItem,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasItems) {
|
||||
setCurrentIndex(initialIndex);
|
||||
translateX.value = -initialIndex * screenWidth;
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentIndex((prev) => {
|
||||
const newIndex = Math.min(prev, items.length - 1);
|
||||
translateX.value = -newIndex * screenWidth;
|
||||
return newIndex;
|
||||
});
|
||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
||||
|
||||
useEffect(() => {
|
||||
translateX.value = -currentIndex * screenWidth;
|
||||
}, [currentIndex, screenWidth, translateX]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasItems) {
|
||||
onItemChange?.(currentIndex);
|
||||
}
|
||||
}, [hasItems, currentIndex, onItemChange]);
|
||||
|
||||
const goToIndex = useCallback(
|
||||
(index: number) => {
|
||||
if (!hasItems || index < 0 || index >= items.length) return;
|
||||
|
||||
translateX.value = withTiming(-index * screenWidth, {
|
||||
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
|
||||
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
|
||||
});
|
||||
|
||||
setCurrentIndex(index);
|
||||
onItemChange?.(index);
|
||||
},
|
||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
||||
);
|
||||
|
||||
const navigateToItem = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, "(home)");
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
||||
.onUpdate((event) => {
|
||||
translateX.value = -currentIndex * screenWidth + event.translationX;
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocity = event.velocityX;
|
||||
const translation = event.translationX;
|
||||
|
||||
let newIndex = currentIndex;
|
||||
|
||||
// Improved thresholds for more responsive navigation
|
||||
if (
|
||||
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
|
||||
Math.abs(velocity) > VELOCITY_THRESHOLD
|
||||
) {
|
||||
if (translation > 0 && currentIndex > 0) {
|
||||
newIndex = currentIndex - 1;
|
||||
} else if (
|
||||
translation < 0 &&
|
||||
items &&
|
||||
currentIndex < items.length - 1
|
||||
) {
|
||||
newIndex = currentIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
runOnJS(goToIndex)(newIndex);
|
||||
});
|
||||
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [{ translateX: translateX.value }],
|
||||
};
|
||||
});
|
||||
|
||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
||||
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
if (!scrollOffset) return {};
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-carouselHeight, 0, carouselHeight],
|
||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-carouselHeight, 0, carouselHeight],
|
||||
[2, 1, 1],
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const renderDots = () => {
|
||||
if (!hasItems || items.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: DOTS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: DOT_GAP,
|
||||
}}
|
||||
>
|
||||
{items.map((_, index) => (
|
||||
<DotIndicator
|
||||
key={index}
|
||||
index={index}
|
||||
currentIndex={currentIndex}
|
||||
onPress={goToIndex}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSkeletonLoader = () => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: carouselHeight,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
>
|
||||
{/* Background Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dark Overlay Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black Top Skeleton */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: GRADIENT_HEIGHT_TOP,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black Bottom Skeleton */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: GRADIENT_HEIGHT_BOTTOM,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: LOGO_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: LOGO_HEIGHT,
|
||||
width: LOGO_WIDTH_PERCENTAGE,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Type and Genres Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: GENRES_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: TEXT_SKELETON_HEIGHT,
|
||||
width: TEXT_SKELETON_WIDTH,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Overview Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: OVERVIEW_SKELETON_HEIGHT,
|
||||
width: OVERVIEW_SKELETON_WIDTH,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: OVERVIEW_SKELETON_HEIGHT,
|
||||
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Controls Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: CONTROLS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: CONTROLS_GAP,
|
||||
}}
|
||||
>
|
||||
{/* Play Button Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
||||
flex: 1,
|
||||
maxWidth: MAX_BUTTON_WIDTH,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Played Status Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
width: PLAYED_STATUS_SKELETON_SIZE,
|
||||
height: PLAYED_STATUS_SKELETON_SIZE,
|
||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Dots Skeleton */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: DOTS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: DOT_GAP,
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3].map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
||||
height: DOT_HEIGHT,
|
||||
backgroundColor:
|
||||
index === 0
|
||||
? SKELETON_ACTIVE_DOT_COLOR
|
||||
: SKELETON_ELEMENT_COLOR,
|
||||
borderRadius: DOT_BORDER_RADIUS,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (item: BaseItemDto, _index: number) => {
|
||||
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={item.Id}
|
||||
style={{
|
||||
width: screenWidth,
|
||||
height: carouselHeight,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Background Backdrop */}
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
},
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<ItemImage
|
||||
item={item}
|
||||
variant='Backdrop'
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Dark Overlay */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black at Top */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: GRADIENT_HEIGHT_TOP,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gradient Fade to Black at Bottom */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: GRADIENT_HEIGHT_BOTTOM,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo Section */}
|
||||
{itemLogoUrl && (
|
||||
<TouchableOpacity
|
||||
onPress={() => navigateToItem(item)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: LOGO_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: itemLogoUrl,
|
||||
}}
|
||||
style={{
|
||||
height: LOGO_HEIGHT,
|
||||
width: LOGO_WIDTH_PERCENTAGE,
|
||||
}}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Type and Genres Section */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: GENRES_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||
<Animated.Text
|
||||
style={{
|
||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
||||
fontSize: GENRES_FONT_SIZE,
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
textShadowColor: TEXT_SHADOW_COLOR,
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
let typeLabel = "";
|
||||
|
||||
if (item.Type === "Episode") {
|
||||
// For episodes, show season and episode number
|
||||
const season = item.ParentIndexNumber;
|
||||
const episode = item.IndexNumber;
|
||||
if (season && episode) {
|
||||
typeLabel = `S${season} • E${episode}`;
|
||||
} else {
|
||||
typeLabel = "Episode";
|
||||
}
|
||||
} else {
|
||||
typeLabel =
|
||||
item.Type === "Series"
|
||||
? "TV Show"
|
||||
: item.Type === "Movie"
|
||||
? "Movie"
|
||||
: item.Type || "";
|
||||
}
|
||||
|
||||
const genres =
|
||||
item.Genres && item.Genres.length > 0
|
||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
||||
: "";
|
||||
|
||||
if (typeLabel && genres) {
|
||||
return `${typeLabel} • ${genres}`;
|
||||
} else if (typeLabel) {
|
||||
return typeLabel;
|
||||
} else if (genres) {
|
||||
return genres;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
</Animated.Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Overview Section - for Episodes and Movies */}
|
||||
{(item.Type === "Episode" || item.Type === "Movie") &&
|
||||
item.Overview && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
||||
<Animated.Text
|
||||
numberOfLines={OVERVIEW_MAX_LINES}
|
||||
style={{
|
||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
||||
fontSize: OVERVIEW_FONT_SIZE,
|
||||
fontWeight: "400",
|
||||
textAlign: "center",
|
||||
maxWidth: OVERVIEW_MAX_WIDTH,
|
||||
textShadowColor: TEXT_SHADOW_COLOR,
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
||||
}}
|
||||
>
|
||||
{item.Overview}
|
||||
</Animated.Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Controls Section */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: CONTROLS_BOTTOM_POSITION,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: CONTROLS_GAP,
|
||||
}}
|
||||
>
|
||||
{/* Play Button */}
|
||||
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
||||
{selectedOptions && (
|
||||
<PlayButton
|
||||
item={item}
|
||||
selectedOptions={selectedOptions}
|
||||
colors={currentItemColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Mark as Played */}
|
||||
<MarkAsPlayedLargeButton
|
||||
isPlayed={item.UserData?.Played ?? false}
|
||||
onToggle={togglePlayedStatus}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Handle loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: carouselHeight,
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{renderSkeletonLoader()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle empty items
|
||||
if (!hasItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: carouselHeight, // Fixed height instead of flex: 1
|
||||
backgroundColor: "#000",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
height: carouselHeight, // Fixed height instead of flex: 1
|
||||
flexDirection: "row",
|
||||
width: screenWidth * items.length,
|
||||
},
|
||||
containerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
{items.map((item, index) => renderItem(item, index))}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Animated Dots Indicator */}
|
||||
{renderDots()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Button, Host } from "@expo/ui/swift-ui";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Platform, View } from "react-native";
|
||||
import { RoundButton } from "../RoundButton";
|
||||
|
||||
interface MarkAsPlayedLargeButtonProps {
|
||||
isPlayed: boolean;
|
||||
onToggle: (isPlayed: boolean) => void;
|
||||
}
|
||||
|
||||
export const MarkAsPlayedLargeButton: React.FC<
|
||||
MarkAsPlayedLargeButtonProps
|
||||
> = ({ isPlayed, onToggle }) => {
|
||||
if (Platform.OS === "ios")
|
||||
return (
|
||||
<Host
|
||||
style={{
|
||||
flex: 0,
|
||||
width: 50,
|
||||
height: 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
||||
<View>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={24}
|
||||
color='white'
|
||||
style={{
|
||||
marginTop: 6,
|
||||
marginLeft: 1,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Button>
|
||||
</Host>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={() => onToggle(isPlayed)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
|
||||
if (Platform.isTV)
|
||||
return (
|
||||
<RNText
|
||||
allowFontScaling={true}
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
@@ -28,6 +28,7 @@ type FavoriteTypes =
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
export const Favorites = () => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -148,7 +149,7 @@ export const Favorites = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
@@ -160,7 +161,7 @@ export const Favorites = () => {
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -598,11 +598,14 @@ const HomeMobile = () => {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
// Render Streamystats sections after Recently Added sections
|
||||
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||
// For custom sections: place at the very end
|
||||
const hasSuggestedMovies =
|
||||
!settings?.streamyStatsMovieRecommendations &&
|
||||
!settings?.home?.sections;
|
||||
const streamystatsIndex =
|
||||
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
|
||||
@@ -30,8 +30,9 @@ import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
@@ -41,8 +42,8 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
// Reduced gap since sections have internal padding for scale animations
|
||||
const SECTION_GAP = 10;
|
||||
// Generous gap between sections for Apple TV+ aesthetic
|
||||
const SECTION_GAP = 24;
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
@@ -61,6 +62,7 @@ type Section = InfiniteScrollingCollectionListSection;
|
||||
const BACKDROP_DEBOUNCE_MS = 300;
|
||||
|
||||
export const Home = () => {
|
||||
const typography = useScaledTVTypography();
|
||||
const _router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -201,6 +203,58 @@ export const Home = () => {
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
// Fetch hero items (Continue Watching + Next Up combined)
|
||||
const { data: heroItems } = useQuery({
|
||||
queryKey: ["home", "heroItems", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Overview"],
|
||||
startIndex: 0,
|
||||
limit: 10,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user.Id,
|
||||
startIndex: 0,
|
||||
limit: 10,
|
||||
fields: ["Overview"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
// Combine, sort by recent activity, and dedupe
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = combined.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const deduped: BaseItemDto[] = [];
|
||||
for (const item of sorted) {
|
||||
if (!item.Id || seen.has(item.Id)) continue;
|
||||
seen.add(item.Id);
|
||||
deduped.push(item);
|
||||
}
|
||||
|
||||
return deduped.slice(0, 8);
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
|
||||
const userViews = useMemo(
|
||||
@@ -526,7 +580,7 @@ export const Home = () => {
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
@@ -538,7 +592,7 @@ export const Home = () => {
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
@@ -579,7 +633,7 @@ export const Home = () => {
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#FFFFFF",
|
||||
@@ -591,7 +645,7 @@ export const Home = () => {
|
||||
style={{
|
||||
textAlign: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
>
|
||||
@@ -607,84 +661,106 @@ export const Home = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
// Determine if hero should be shown (separate setting from backdrop)
|
||||
const showHero =
|
||||
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||
{/* Dynamic backdrop with crossfade */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{/* Layer 0 */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: layer0Opacity,
|
||||
}}
|
||||
>
|
||||
{layer0Url && (
|
||||
<Image
|
||||
source={{ uri: layer0Url }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
{/* Layer 1 */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: layer1Opacity,
|
||||
}}
|
||||
>
|
||||
{layer1Url && (
|
||||
<Image
|
||||
source={{ uri: layer1Url }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
{/* Gradient overlays for readability */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||
locations={[0, 0.4, 1]}
|
||||
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
|
||||
{!showHero && settings.showHomeBackdrop && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
>
|
||||
{/* Layer 0 */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: layer0Opacity,
|
||||
}}
|
||||
>
|
||||
{layer0Url && (
|
||||
<Image
|
||||
source={{ uri: layer0Url }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
{/* Layer 1 */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: layer1Opacity,
|
||||
}}
|
||||
>
|
||||
{layer1Url && (
|
||||
<Image
|
||||
source={{ uri: layer1Url }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
{/* Gradient overlays for readability */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||
locations={[0, 0.4, 1]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + TOP_PADDING,
|
||||
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
{/* Hero Carousel - Apple TV+ style featured content */}
|
||||
{showHero && (
|
||||
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
gap: SECTION_GAP,
|
||||
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||
paddingTop: showHero ? SECTION_GAP : 0,
|
||||
}}
|
||||
>
|
||||
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
|
||||
{sections.slice(showHero ? 1 : 0).map((section, index) => {
|
||||
// Render Streamystats sections after Recently Added sections
|
||||
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||
// For custom sections: place at the very end
|
||||
const hasSuggestedMovies =
|
||||
!settings?.streamyStatsMovieRecommendations &&
|
||||
!settings?.home?.sections;
|
||||
// Adjust index calculation to account for sliced array when hero is shown
|
||||
const displayedSectionsLength =
|
||||
sections.length - (showHero ? 1 : 0);
|
||||
const streamystatsIndex =
|
||||
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
@@ -723,7 +799,8 @@ export const Home = () => {
|
||||
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
const isHighPriority = section.priority === 1;
|
||||
const isFirstSection = index === 0;
|
||||
// First section only gets preferred focus if hero is not shown
|
||||
const isFirstSection = index === 0 && !showHero;
|
||||
return (
|
||||
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||
<InfiniteScrollingCollectionList
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
getItemsApi,
|
||||
getSuggestionsApi,
|
||||
getTvShowsApi,
|
||||
getUserLibraryApi,
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedRef,
|
||||
useScrollViewOffset,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
title?: string;
|
||||
queryKey: (string | undefined | null)[];
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
|
||||
export const HomeWithCarousel = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [_loading, setLoading] = useState(false);
|
||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
||||
const navigation = useNavigation();
|
||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
||||
const prevIsConnected = useRef<boolean | null>(false);
|
||||
const {
|
||||
isConnected,
|
||||
serverConnected,
|
||||
loading: retryLoading,
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
invalidateCache();
|
||||
}
|
||||
prevIsConnected.current = isConnected;
|
||||
}, [isConnected, invalidateCache]);
|
||||
|
||||
const hasDownloads = useMemo(() => {
|
||||
if (Platform.isTV) return false;
|
||||
return downloadedItems.length > 0;
|
||||
}, [downloadedItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) {
|
||||
navigation.setOptions({
|
||||
headerLeft: () => null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
className='ml-1.5'
|
||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
||||
>
|
||||
<Feather
|
||||
name='download'
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, router, hasDownloads]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanCacheDirectory().catch((_e) =>
|
||||
console.error("Something went wrong cleaning cache directory"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const segments = useSegments();
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||
if ((segments as string[])[2] === "(home)")
|
||||
animatedScrollRef.current?.scrollTo({
|
||||
y: Platform.isTV ? -152 : -100,
|
||||
animated: true,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isError: e1,
|
||||
isLoading: l1,
|
||||
} = useQuery({
|
||||
queryKey: ["home", "userViews", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await getUserViewsApi(api).getUserViews({
|
||||
userId: user.Id,
|
||||
});
|
||||
|
||||
return response.data.Items || null;
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const userViews = useMemo(
|
||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
const collections = useMemo(() => {
|
||||
const allow = ["movies", "tvshows"];
|
||||
return (
|
||||
userViews?.filter(
|
||||
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
||||
) || []
|
||||
);
|
||||
}, [userViews]);
|
||||
|
||||
const _refetch = async () => {
|
||||
setLoading(true);
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const createCollectionConfig = useCallback(
|
||||
(
|
||||
title: string,
|
||||
queryKey: string[],
|
||||
includeItemTypes: BaseItemKind[],
|
||||
parentId: string | undefined,
|
||||
pageSize: number = 10,
|
||||
): InfiniteScrollingCollectionListSection => ({
|
||||
title,
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (!api) return [];
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
|
||||
const defaultSections = useMemo(() => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const latestMediaViews = collections.map((c) => {
|
||||
const includeItemTypes: BaseItemKind[] =
|
||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
||||
? []
|
||||
: ["Movie"];
|
||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
||||
const queryKey: string[] = [
|
||||
"home",
|
||||
`recentlyAddedIn${c.CollectionType}`,
|
||||
user.Id!,
|
||||
c.Id!,
|
||||
];
|
||||
return createCollectionConfig(
|
||||
title || "",
|
||||
queryKey,
|
||||
includeItemTypes,
|
||||
c.Id,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to sort items by most recent activity
|
||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
return items.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to deduplicate items by ID
|
||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (!item.Id || seen.has(item.Id)) return false;
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Build the first sections based on merge setting
|
||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||
? [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
queryKey: ["home", "continueAndNextUp"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
// Fetch both in parallel
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
// Combine, sort by recent activity, deduplicate
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = sortByRecentActivity(combined);
|
||||
const deduplicated = deduplicateById(sorted);
|
||||
|
||||
// Paginate client-side
|
||||
return deduplicated.slice(pageParam, pageParam + 10);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const ss: Section[] = [
|
||||
...firstSections,
|
||||
...latestMediaViews,
|
||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||
...(!settings?.streamyStatsMovieRecommendations
|
||||
? [
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ss;
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
const ss: Section[] = [];
|
||||
settings.home.sections.forEach((section, index) => {
|
||||
const id = section.title || `section-${index}`;
|
||||
const pageSize = 10;
|
||||
ss.push({
|
||||
title: t(`${id}`),
|
||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
if (section.items) {
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: section.items?.limit || pageSize,
|
||||
recursive: true,
|
||||
includeItemTypes: section.items?.includeItemTypes,
|
||||
sortBy: section.items?.sortBy,
|
||||
sortOrder: section.items?.sortOrder,
|
||||
filters: section.items?.filters,
|
||||
parentId: section.items?.parentId,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
return response.data.Items || [];
|
||||
}
|
||||
if (section.latest) {
|
||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
||||
const allData =
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
).data || [];
|
||||
|
||||
// Simulate pagination by slicing
|
||||
return allData.slice(pageParam, pageParam + pageSize);
|
||||
}
|
||||
if (section.custom) {
|
||||
const response = await api.get<BaseItemDtoQueryResult>(
|
||||
section.custom.endpoint,
|
||||
{
|
||||
params: {
|
||||
...(section.custom.query || {}),
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: pageSize,
|
||||
},
|
||||
headers: section.custom.headers || {},
|
||||
},
|
||||
);
|
||||
return response.data.Items || [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
if (!isConnected || serverConnected !== true) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
|
||||
if (!isConnected) {
|
||||
title = t("home.no_internet");
|
||||
subtitle = t("home.no_internet_message");
|
||||
} else if (serverConnected === null) {
|
||||
title = t("home.checking_server_connection");
|
||||
subtitle = t("home.checking_server_connection_message");
|
||||
} else if (!serverConnected) {
|
||||
title = t("home.server_unreachable");
|
||||
subtitle = t("home.server_unreachable_message");
|
||||
}
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
||||
|
||||
<View className='mt-4'>
|
||||
{!Platform.isTV && (
|
||||
<Button
|
||||
color='purple'
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify='center'
|
||||
iconRight={
|
||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
||||
}
|
||||
>
|
||||
{t("home.go_to_downloads")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color='black'
|
||||
onPress={retryCheck}
|
||||
justify='center'
|
||||
className='mt-2'
|
||||
iconRight={
|
||||
retryLoading ? null : (
|
||||
<Ionicons name='refresh' size={20} color='white' />
|
||||
)
|
||||
}
|
||||
>
|
||||
{retryLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("home.retry")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (e1)
|
||||
return (
|
||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
||||
<Text className='text-center opacity-70'>
|
||||
{t("home.error_message")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
scrollToOverflowEnabled={true}
|
||||
ref={animatedScrollRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='never'
|
||||
scrollEventThrottle={16}
|
||||
bounces={false}
|
||||
overScrollMode='never'
|
||||
style={{ marginTop: -headerOverlayOffset }}
|
||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y);
|
||||
}}
|
||||
>
|
||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
||||
<View
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 0,
|
||||
}}
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||
<>
|
||||
{settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists />
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<View className='h-24' />
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
};
|
||||
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
|
||||
@@ -20,7 +20,7 @@ import MoviePoster, {
|
||||
TV_POSTER_WIDTH,
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import ContinueWatchingPoster, {
|
||||
@@ -28,7 +28,7 @@ import ContinueWatchingPoster, {
|
||||
} from "../ContinueWatchingPoster.tv";
|
||||
import SeriesPoster from "../posters/SeriesPoster.tv";
|
||||
|
||||
const ITEM_GAP = 16;
|
||||
const ITEM_GAP = 24;
|
||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
@@ -48,22 +48,27 @@ interface Props extends ViewProps {
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||
|
||||
// TV-specific ItemCardText with larger fonts
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const TVItemCardText: React.FC<{
|
||||
item: BaseItemDto;
|
||||
typography: Typography;
|
||||
}> = ({ item, typography }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
@@ -77,13 +82,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
<>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
@@ -103,7 +108,8 @@ const TVSeeAllCard: React.FC<{
|
||||
disabled?: boolean;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
|
||||
typography: Typography;
|
||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
|
||||
const { t } = useTranslation();
|
||||
const width =
|
||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||
@@ -137,7 +143,7 @@ const TVSeeAllCard: React.FC<{
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
@@ -165,6 +171,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
parentId,
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const effectivePageSize = Math.max(1, pageSize);
|
||||
const hasCalledOnLoaded = useRef(false);
|
||||
const router = useRouter();
|
||||
@@ -211,7 +218,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
@@ -343,7 +350,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
>
|
||||
{renderPoster()}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
<TVItemCardText item={item} typography={typography} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
@@ -354,6 +361,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
handleItemPress,
|
||||
handleItemFocus,
|
||||
handleItemBlur,
|
||||
typography,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -365,11 +373,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
{/* Section Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontWeight: "600",
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
marginLeft: SCALE_PADDING,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -379,7 +388,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
<Text
|
||||
style={{
|
||||
color: "#737373",
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
@@ -420,7 +429,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
color: "#262626",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 6,
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -477,6 +486,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
disabled={disabled}
|
||||
onFocus={handleSeeAllFocus}
|
||||
onBlur={handleItemBlur}
|
||||
typography={typography}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey: queryKey,
|
||||
queryFn,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled: enableLazyLoading ? isInView : true,
|
||||
|
||||
@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -26,18 +26,23 @@ import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
const ITEM_GAP = 16;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||
|
||||
const TVItemCardText: React.FC<{
|
||||
item: BaseItemDto;
|
||||
typography: Typography;
|
||||
}> = ({ item, typography }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
@@ -60,6 +65,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
onItemFocus,
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
@@ -108,8 +114,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
Boolean(settings?.streamyStatsServerUrl) &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -142,11 +148,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
<TVItemCardText item={item} typography={typography} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleItemPress, onItemFocus],
|
||||
[handleItemPress, onItemFocus, typography],
|
||||
);
|
||||
|
||||
if (!isLoading && (!items || items.length === 0)) return null;
|
||||
@@ -155,11 +161,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
<View style={{ overflow: "visible" }} {...props}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontWeight: "600",
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
marginLeft: SCALE_PADDING,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{watchlist.name}
|
||||
@@ -278,8 +285,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
enabled:
|
||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,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 { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -26,6 +26,8 @@ import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystat
|
||||
const ITEM_GAP = 16;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title: string;
|
||||
type: "Movie" | "Series";
|
||||
@@ -34,18 +36,21 @@ interface Props extends ViewProps {
|
||||
onItemFocus?: (item: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const TVItemCardText: React.FC<{
|
||||
item: BaseItemDto;
|
||||
typography: Typography;
|
||||
}> = ({ item, typography }) => {
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
@@ -64,6 +69,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
onItemFocus,
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
@@ -133,8 +139,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -166,8 +172,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
},
|
||||
enabled:
|
||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
@@ -203,11 +209,11 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
</TVFocusablePoster>
|
||||
<TVItemCardText item={item} />
|
||||
<TVItemCardText item={item} typography={typography} />
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleItemPress, onItemFocus],
|
||||
[handleItemPress, onItemFocus, typography],
|
||||
);
|
||||
|
||||
if (!streamyStatsEnabled) return null;
|
||||
@@ -218,11 +224,12 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
<View style={{ overflow: "visible" }} {...props}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontWeight: "600",
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
marginLeft: SCALE_PADDING,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
624
components/home/TVHeroCarousel.tsx
Normal file
624
components/home/TVHeroCarousel.tsx
Normal file
@@ -0,0 +1,624 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Easing,
|
||||
FlatList,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
|
||||
const CARD_WIDTH = 280;
|
||||
const CARD_GAP = 24;
|
||||
const CARD_PADDING = 60;
|
||||
|
||||
interface TVHeroCarouselProps {
|
||||
items: BaseItemDto[];
|
||||
onItemFocus?: (item: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
interface HeroCardProps {
|
||||
item: BaseItemDto;
|
||||
isFirst: boolean;
|
||||
onFocus: (item: BaseItemDto) => void;
|
||||
onPress: (item: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||
({ item, isFirst, onFocus, onPress }) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||
|
||||
const posterUrl = useMemo(() => {
|
||||
if (!api) return null;
|
||||
|
||||
// For episodes, always use series thumb
|
||||
if (item.Type === "Episode") {
|
||||
if (item.ParentThumbImageTag) {
|
||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
if (item.SeriesId) {
|
||||
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-episodes, use item's own thumb/primary
|
||||
if (item.ImageTags?.Thumb) {
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
|
||||
}
|
||||
if (item.ImageTags?.Primary) {
|
||||
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
|
||||
}
|
||||
return null;
|
||||
}, [api, item]);
|
||||
|
||||
const progress = item.UserData?.PlayedPercentage || 0;
|
||||
|
||||
const animateTo = useCallback(
|
||||
(value: number) =>
|
||||
Animated.timing(scale, {
|
||||
toValue: value,
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}).start(),
|
||||
[scale],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
animateTo(1.1);
|
||||
onFocus(item);
|
||||
}, [animateTo, onFocus, item]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
animateTo(1);
|
||||
}, [animateTo]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(item);
|
||||
}, [onPress, item]);
|
||||
|
||||
// Use glass poster for tvOS 26+
|
||||
if (useGlass) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst}
|
||||
style={{ marginRight: CARD_GAP }}
|
||||
>
|
||||
<GlassPosterView
|
||||
imageUrl={posterUrl}
|
||||
aspectRatio={16 / 9}
|
||||
cornerRadius={16}
|
||||
progress={progress}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={focused}
|
||||
width={CARD_WIDTH}
|
||||
style={{ width: CARD_WIDTH }}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for non-tvOS or older tvOS
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirst}
|
||||
style={{ marginRight: CARD_GAP }}
|
||||
>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: CARD_WIDTH,
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
transform: [{ scale }],
|
||||
shadowColor: "#FFFFFF",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='film-outline'
|
||||
size={48}
|
||||
color='rgba(255,255,255,0.3)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProgressBar item={item} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Debounce delay to prevent rapid backdrop changes when navigating fast
|
||||
const BACKDROP_DEBOUNCE_MS = 300;
|
||||
|
||||
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||
items,
|
||||
onItemFocus,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const api = useAtomValue(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
// Active item for featured display (debounced)
|
||||
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
|
||||
items[0] || null,
|
||||
);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Crossfade animation state
|
||||
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
||||
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Get backdrop URL for active item
|
||||
const backdropUrl = useMemo(() => {
|
||||
if (!activeItem) return null;
|
||||
return getBackdropUrl({
|
||||
api,
|
||||
item: activeItem,
|
||||
quality: 90,
|
||||
width: 1920,
|
||||
});
|
||||
}, [api, activeItem]);
|
||||
|
||||
// Get logo URL for active item
|
||||
const logoUrl = useMemo(() => {
|
||||
if (!activeItem) return null;
|
||||
return getLogoImageUrlById({ api, item: activeItem });
|
||||
}, [api, activeItem]);
|
||||
|
||||
// Crossfade effect for backdrop
|
||||
useEffect(() => {
|
||||
if (!backdropUrl) return;
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const performCrossfade = async () => {
|
||||
try {
|
||||
await Image.prefetch(backdropUrl);
|
||||
} catch {
|
||||
// Continue even if prefetch fails
|
||||
}
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||
const incomingOpacity =
|
||||
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||
const outgoingOpacity =
|
||||
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||
|
||||
if (incomingLayer === 0) {
|
||||
setLayer0Url(backdropUrl);
|
||||
} else {
|
||||
setLayer1Url(backdropUrl);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
if (isCancelled) return;
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(incomingOpacity, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(outgoingOpacity, {
|
||||
toValue: 0,
|
||||
duration: 500,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
if (!isCancelled) {
|
||||
setActiveLayer(incomingLayer);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
performCrossfade();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [backdropUrl]);
|
||||
|
||||
// Handle card focus with debounce
|
||||
const handleCardFocus = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
// Clear any pending debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
// Set new timer to update active item after debounce delay
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
setActiveItem(item);
|
||||
onItemFocus?.(item);
|
||||
}, BACKDROP_DEBOUNCE_MS);
|
||||
},
|
||||
[onItemFocus],
|
||||
);
|
||||
|
||||
// Handle card press - navigate to item
|
||||
const handleCardPress = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const navigation = getItemNavigation(item, "(home)");
|
||||
router.push(navigation as any);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
// Get metadata for active item
|
||||
const year = activeItem?.ProductionYear;
|
||||
const duration = activeItem?.RunTimeTicks
|
||||
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
|
||||
: null;
|
||||
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
|
||||
|
||||
// Get display title
|
||||
const displayTitle = useMemo(() => {
|
||||
if (!activeItem) return "";
|
||||
if (activeItem.Type === "Episode") {
|
||||
return activeItem.SeriesName || activeItem.Name || "";
|
||||
}
|
||||
return activeItem.Name || "";
|
||||
}, [activeItem]);
|
||||
|
||||
// Get subtitle for episodes
|
||||
const episodeSubtitle = useMemo(() => {
|
||||
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||
}, [activeItem]);
|
||||
|
||||
// Memoize hero items to prevent re-renders
|
||||
const heroItems = useMemo(() => items.slice(0, 8), [items]);
|
||||
|
||||
// Memoize renderItem for FlatList
|
||||
const renderHeroCard = useCallback(
|
||||
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||
<HeroCard
|
||||
item={item}
|
||||
isFirst={index === 0}
|
||||
onFocus={handleCardFocus}
|
||||
onPress={handleCardPress}
|
||||
/>
|
||||
),
|
||||
[handleCardFocus, handleCardPress],
|
||||
);
|
||||
|
||||
// Memoize keyExtractor
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ height: HERO_HEIGHT, width: "100%" }}>
|
||||
{/* Backdrop layers with crossfade */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{/* Layer 0 */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: layer0Opacity,
|
||||
}}
|
||||
>
|
||||
{layer0Url && (
|
||||
<Image
|
||||
source={{ uri: layer0Url }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
{/* Layer 1 */}
|
||||
<Animated.View
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: layer1Opacity,
|
||||
}}
|
||||
>
|
||||
{layer1Url && (
|
||||
<Image
|
||||
source={{ uri: layer1Url }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Gradient overlays */}
|
||||
<LinearGradient
|
||||
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
|
||||
locations={[0, 0.5, 1]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: "70%",
|
||||
}}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.4)", "transparent"]}
|
||||
locations={[0, 1]}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: "40%",
|
||||
}}
|
||||
/>
|
||||
{/* Horizontal gradient for left side text contrast */}
|
||||
<LinearGradient
|
||||
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
|
||||
locations={[0, 0.5, 0.85]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "70%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content overlay */}
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: insets.left + CARD_PADDING,
|
||||
right: insets.right + CARD_PADDING,
|
||||
bottom: 40,
|
||||
}}
|
||||
>
|
||||
{/* Logo or Title */}
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
source={{ uri: logoUrl }}
|
||||
style={{
|
||||
height: 100,
|
||||
width: SCREEN_WIDTH * 0.35,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
contentFit='contain'
|
||||
contentPosition='left'
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Episode subtitle */}
|
||||
{episodeSubtitle && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{episodeSubtitle}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{activeItem?.Overview && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
marginBottom: 16,
|
||||
maxWidth: SCREEN_WIDTH * 0.5,
|
||||
lineHeight: typography.body * 1.4,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{activeItem.Overview}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Metadata badges */}
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Text>
|
||||
)}
|
||||
{duration && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
{duration}
|
||||
</Text>
|
||||
)}
|
||||
{activeItem?.OfficialRating && (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
{activeItem.OfficialRating}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{hasProgress && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 60,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: `${playedPercent}%`,
|
||||
height: "100%",
|
||||
backgroundColor: "#FFFFFF",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.8)",
|
||||
}}
|
||||
>
|
||||
{Math.round(playedPercent)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Thumbnail carousel */}
|
||||
<FlatList
|
||||
horizontal
|
||||
data={heroItems}
|
||||
keyExtractor={keyExtractor}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{ paddingVertical: 12 }}
|
||||
renderItem={renderHeroCard}
|
||||
removeClippedSubviews={false}
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={3}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
type DiscoverEndpoint,
|
||||
@@ -33,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
item,
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const router = useRouter();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
@@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
@@ -142,7 +143,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
@@ -164,6 +165,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
slide,
|
||||
isFirstSlide = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
@@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Loader } from "@/components/Loader";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import { TVButton } from "@/components/tv";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||
@@ -68,6 +68,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
onPress,
|
||||
refSetter,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
@@ -128,7 +129,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
@@ -158,6 +159,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
||||
};
|
||||
|
||||
export const TVJellyseerrPage: React.FC = () => {
|
||||
const typography = useScaledTVTypography();
|
||||
const insets = useSafeAreaInsets();
|
||||
const params = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
@@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.display,
|
||||
fontSize: typography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginTop: 8,
|
||||
@@ -566,7 +568,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
{/* Year */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
@@ -601,7 +603,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#E5E7EB",
|
||||
lineHeight: 32,
|
||||
}}
|
||||
@@ -636,7 +638,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
}}
|
||||
@@ -663,7 +665,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
@@ -698,7 +700,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
@@ -732,7 +734,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
@@ -757,7 +759,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginLeft: 8,
|
||||
}}
|
||||
@@ -776,7 +778,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
@@ -794,7 +796,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
@@ -813,7 +815,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
||||
<View style={{ marginTop: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type {
|
||||
QualityProfile,
|
||||
@@ -51,6 +51,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
||||
onClose,
|
||||
onRequested,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
@@ -389,7 +390,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
@@ -399,7 +400,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
@@ -473,7 +474,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
interface TVRequestOptionRowProps {
|
||||
label: string;
|
||||
@@ -20,6 +20,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.02,
|
||||
@@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
@@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
interface ToggleItem {
|
||||
id: number;
|
||||
@@ -21,6 +21,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.08,
|
||||
@@ -57,7 +58,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: item.selected || focused ? "600" : "400",
|
||||
}}
|
||||
@@ -82,13 +83,14 @@ export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
|
||||
onToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Animated, Easing, FlatList, Pressable, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -55,6 +56,7 @@ const TVLibraryRow: React.FC<{
|
||||
}> = ({ library, isFirst, onPress }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const opacity = useRef(new Animated.Value(0.7)).current;
|
||||
@@ -190,7 +192,7 @@ const TVLibraryRow: React.FC<{
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: 32,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
textShadowColor: "rgba(0,0,0,0.8)",
|
||||
@@ -203,7 +205,7 @@ const TVLibraryRow: React.FC<{
|
||||
{library.itemCount !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
marginTop: 4,
|
||||
textShadowColor: "rgba(0,0,0,0.8)",
|
||||
@@ -237,6 +239,7 @@ export const TVLibraries: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
@@ -360,7 +363,7 @@ export const TVLibraries: React.FC = () => {
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20, color: "#737373" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||
{t("library.no_libraries_found")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -44,6 +45,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
const url = useMemo(
|
||||
() =>
|
||||
@@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginTop: 12,
|
||||
@@ -160,7 +162,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
||||
{itemsCount !== undefined && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 4,
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
TextInput,
|
||||
type TextInputProps,
|
||||
} from "react-native";
|
||||
import { fontSize, size } from "react-native-responsive-sizes";
|
||||
|
||||
interface TVInputProps extends TextInputProps {
|
||||
label?: string;
|
||||
@@ -59,8 +58,8 @@ export const TVInput: React.FC<TVInputProps> = ({
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
borderRadius: size(20),
|
||||
borderWidth: size(6),
|
||||
borderRadius: 10,
|
||||
borderWidth: 3,
|
||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||
}}
|
||||
>
|
||||
@@ -70,8 +69,8 @@ export const TVInput: React.FC<TVInputProps> = ({
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
{
|
||||
height: size(200),
|
||||
fontSize: fontSize(12),
|
||||
height: 68,
|
||||
fontSize: 24,
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
style,
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ScrollView,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { fontSize, size } from "react-native-responsive-sizes";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -67,7 +66,7 @@ const TVBackButton: React.FC<{
|
||||
setIsFocused(false);
|
||||
animateFocus(false);
|
||||
}}
|
||||
style={{ alignSelf: "flex-start", marginBottom: size(40) }}
|
||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
@@ -76,25 +75,26 @@ const TVBackButton: React.FC<{
|
||||
transform: [{ scale }],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: size(12),
|
||||
borderRadius: size(8),
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: isFocused
|
||||
? "rgba(168, 85, 247, 0.2)"
|
||||
: "transparent",
|
||||
borderWidth: size(2),
|
||||
borderWidth: 2,
|
||||
borderColor: isFocused ? Colors.primary : "transparent",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='chevron-back'
|
||||
size={size(28)}
|
||||
size={28}
|
||||
color={isFocused ? "#FFFFFF" : Colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
color: isFocused ? "#FFFFFF" : Colors.primary,
|
||||
fontSize: fontSize(14),
|
||||
marginLeft: size(4),
|
||||
fontSize: 20,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -463,7 +463,7 @@ export const TVLogin: React.FC = () => {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingVertical: size(20),
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
@@ -471,7 +471,7 @@ export const TVLogin: React.FC = () => {
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: size(40),
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Back Button */}
|
||||
@@ -484,10 +484,10 @@ export const TVLogin: React.FC = () => {
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(12),
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: size(8),
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{serverName ? (
|
||||
@@ -501,18 +501,16 @@ export const TVLogin: React.FC = () => {
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(14),
|
||||
fontSize: 18,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: size(40),
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{api.basePath}
|
||||
</Text>
|
||||
|
||||
{/* Username Input - extra padding for focus scale */}
|
||||
<View
|
||||
style={{ marginBottom: size(24), paddingHorizontal: size(8) }}
|
||||
>
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.username_placeholder")}
|
||||
value={credentials.username}
|
||||
@@ -529,9 +527,7 @@ export const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View
|
||||
style={{ marginBottom: size(32), paddingHorizontal: size(8) }}
|
||||
>
|
||||
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("login.password_placeholder")}
|
||||
value={credentials.password}
|
||||
@@ -547,9 +543,7 @@ export const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Save Account Toggle */}
|
||||
<View
|
||||
style={{ marginBottom: size(40), paddingHorizontal: size(8) }}
|
||||
>
|
||||
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||
<TVSaveAccountToggle
|
||||
value={saveAccount}
|
||||
onValueChange={setSaveAccount}
|
||||
@@ -559,7 +553,7 @@ export const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Login Button */}
|
||||
<View style={{ marginBottom: size(16) }}>
|
||||
<View style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
@@ -585,11 +579,9 @@ export const TVLogin: React.FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
// justifyContent: "center",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
paddingTop: size(40),
|
||||
paddingBottom: size(40),
|
||||
paddingVertical: 60,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
@@ -597,14 +589,14 @@ export const TVLogin: React.FC = () => {
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 800,
|
||||
paddingHorizontal: size(60),
|
||||
paddingHorizontal: 60,
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<View style={{ alignItems: "center", marginBottom: size(5) }}>
|
||||
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||
<Image
|
||||
source={require("@/assets/images/icon-tvos.png")}
|
||||
style={{ width: size(400), height: size(400) }}
|
||||
style={{ width: 150, height: 150 }}
|
||||
contentFit='contain'
|
||||
/>
|
||||
</View>
|
||||
@@ -612,30 +604,28 @@ export const TVLogin: React.FC = () => {
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(24),
|
||||
fontSize: 48,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
marginBottom: size(8),
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Streamyfin
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(10),
|
||||
fontSize: 20,
|
||||
color: "#9CA3AF",
|
||||
textAlign: "center",
|
||||
marginBottom: size(40),
|
||||
marginBottom: 40,
|
||||
}}
|
||||
>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
|
||||
{/* Server URL Input - extra padding for focus scale */}
|
||||
<View
|
||||
style={{ marginBottom: size(50), paddingHorizontal: size(300) }}
|
||||
>
|
||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||
<TVInput
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
value={serverURL}
|
||||
@@ -650,9 +640,7 @@ export const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Connect Button */}
|
||||
<View
|
||||
style={{ marginBottom: size(12), paddingHorizontal: size(270) }}
|
||||
>
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Button
|
||||
onPress={() => handleConnect(serverURL)}
|
||||
loading={loadingServerCheck}
|
||||
@@ -663,7 +651,7 @@ export const TVLogin: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* Previous Servers */}
|
||||
<View style={{ paddingHorizontal: size(300) }}>
|
||||
<View style={{ paddingHorizontal: 8 }}>
|
||||
<TVPreviousServersList
|
||||
onServerSelect={(s) => handleConnect(s.address)}
|
||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { fontSize, height, size, width } from "react-native-responsive-sizes";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
@@ -62,8 +61,8 @@ const TVServerActionCard: React.FC<{
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale }],
|
||||
width: width(25),
|
||||
height: height(15),
|
||||
width: 180,
|
||||
height: 90,
|
||||
backgroundColor: focused
|
||||
? isDestructive
|
||||
? "#ef4444"
|
||||
@@ -71,16 +70,16 @@ const TVServerActionCard: React.FC<{
|
||||
: isDestructive
|
||||
? "rgba(239, 68, 68, 0.2)"
|
||||
: "rgba(255,255,255,0.08)",
|
||||
borderRadius: size(20),
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: size(12),
|
||||
gap: size(8),
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size(56)}
|
||||
size={28}
|
||||
color={
|
||||
focused
|
||||
? isDestructive
|
||||
@@ -93,7 +92,7 @@ const TVServerActionCard: React.FC<{
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(12),
|
||||
fontSize: 16,
|
||||
color: focused
|
||||
? isDestructive
|
||||
? "#fff"
|
||||
@@ -143,26 +142,26 @@ const TVServerActionSheet: React.FC<{
|
||||
intensity={80}
|
||||
tint='dark'
|
||||
style={{
|
||||
borderTopLeftRadius: size(24),
|
||||
borderTopRightRadius: size(24),
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
paddingTop: size(24),
|
||||
paddingBottom: size(50),
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(12),
|
||||
fontSize: 18,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: size(12),
|
||||
paddingHorizontal: size(48),
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
@@ -176,9 +175,9 @@ const TVServerActionSheet: React.FC<{
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ overflow: "visible" }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: size(48),
|
||||
paddingVertical: size(10),
|
||||
gap: size(12),
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<TVServerActionCard
|
||||
@@ -399,19 +398,19 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||
if (!previousServers.length) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: size(100) }}>
|
||||
<View style={{ marginTop: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(12),
|
||||
fontSize: 24,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: size(20),
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{t("server.previous_servers")}
|
||||
</Text>
|
||||
|
||||
<View style={{ gap: size(12) }}>
|
||||
<View style={{ gap: 12 }}>
|
||||
{previousServers.map((server) => (
|
||||
<TVServerCard
|
||||
key={server.address}
|
||||
@@ -438,39 +437,39 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: size(80),
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#1a1a1a",
|
||||
borderRadius: size(24),
|
||||
padding: size(40),
|
||||
borderRadius: 24,
|
||||
padding: 40,
|
||||
width: "100%",
|
||||
maxWidth: width(70),
|
||||
maxWidth: 700,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(32),
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: size(8),
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("server.select_account")}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(18),
|
||||
fontSize: 18,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: size(32),
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
{selectedServer?.name || selectedServer?.address}
|
||||
</Text>
|
||||
|
||||
<View style={{ gap: size(12), marginBottom: size(24) }}>
|
||||
<View style={{ gap: 12, marginBottom: 24 }}>
|
||||
{selectedServer?.accounts.map((account, index) => (
|
||||
<TVAccountCard
|
||||
key={account.userId}
|
||||
@@ -485,7 +484,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={{ gap: size(12) }}>
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setShowAccountsModal(false);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Pressable,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { fontSize, size } from "react-native-responsive-sizes";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
|
||||
@@ -87,11 +86,11 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||
borderWidth: size(6),
|
||||
borderWidth: 2,
|
||||
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||
borderRadius: size(50),
|
||||
paddingHorizontal: size(50),
|
||||
paddingVertical: size(40),
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 20,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
@@ -100,7 +99,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(12),
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
@@ -111,9 +110,9 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: fontSize(10),
|
||||
fontSize: 16,
|
||||
color: "#9CA3AF",
|
||||
marginTop: size(12),
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -122,27 +121,27 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ marginLeft: size(16) }}>
|
||||
<View style={{ marginLeft: 16 }}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color={Colors.primary} />
|
||||
) : securityIcon ? (
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Ionicons
|
||||
name={securityIcon}
|
||||
size={size(60)}
|
||||
size={20}
|
||||
color={Colors.primary}
|
||||
style={{ marginRight: size(16) }}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={size(48)}
|
||||
size={24}
|
||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<Ionicons
|
||||
name='chevron-forward'
|
||||
size={size(48)}
|
||||
size={24}
|
||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,10 +4,14 @@ import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
export const TV_POSTER_WIDTH = 210;
|
||||
export const TV_POSTER_WIDTH = 260;
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -24,17 +28,37 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 420, // 2x for quality on large screens
|
||||
width: 520, // 2x for quality on large screens
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
const progress = item.UserData?.PlayedPercentage || 0;
|
||||
const isWatched = item.UserData?.Played === true;
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
// Use glass effect on tvOS 26+
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
if (useGlass) {
|
||||
return (
|
||||
<GlassPosterView
|
||||
imageUrl={url ?? null}
|
||||
aspectRatio={10 / 15}
|
||||
cornerRadius={24}
|
||||
progress={showProgress ? progress : 0}
|
||||
showWatchedIndicator={isWatched}
|
||||
isFocused={false}
|
||||
width={TV_POSTER_WIDTH}
|
||||
style={{ width: TV_POSTER_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -3,10 +3,14 @@ import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
export const TV_POSTER_WIDTH = 210;
|
||||
export const TV_POSTER_WIDTH = 260;
|
||||
|
||||
type SeriesPosterProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -18,12 +22,12 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (item.Type === "Episode") {
|
||||
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||
}
|
||||
return getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
width: 420, // 2x for quality on large screens
|
||||
width: 520, // 2x for quality on large screens
|
||||
});
|
||||
}, [api, item]);
|
||||
|
||||
@@ -32,6 +36,25 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
// Use glass effect on tvOS 26+
|
||||
const useGlass = isGlassEffectAvailable();
|
||||
|
||||
if (useGlass) {
|
||||
return (
|
||||
<GlassPosterView
|
||||
imageUrl={url ?? null}
|
||||
aspectRatio={10 / 15}
|
||||
cornerRadius={24}
|
||||
progress={0}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={false}
|
||||
width={TV_POSTER_WIDTH}
|
||||
style={{ width: TV_POSTER_WIDTH }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for older tvOS versions
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type {
|
||||
@@ -27,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
onPress,
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
@@ -113,7 +114,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
@@ -125,7 +126,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
@@ -147,6 +148,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
item,
|
||||
onPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
@@ -202,7 +204,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
@@ -230,13 +232,14 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
@@ -281,13 +284,14 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
@@ -332,13 +336,14 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
isFirstSection: _isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Animated, Pressable } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
export interface TVSearchBadgeProps {
|
||||
label: string;
|
||||
@@ -14,6 +15,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||
|
||||
@@ -41,7 +43,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused ? "600" : "400",
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
@@ -26,6 +27,7 @@ const SCALE_PADDING = 20;
|
||||
|
||||
// Loading skeleton for TV
|
||||
const TVLoadingSkeleton: React.FC = () => {
|
||||
const typography = useScaledTVTypography();
|
||||
const itemWidth = 210;
|
||||
return (
|
||||
<View style={{ overflow: "visible" }}>
|
||||
@@ -71,7 +73,7 @@ const TVLoadingSkeleton: React.FC = () => {
|
||||
color: "#262626",
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 6,
|
||||
fontSize: 16,
|
||||
fontSize: typography.callout,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -149,6 +151,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
onJellyseerrPersonPress,
|
||||
discoverSliders,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -222,7 +225,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
|
||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING + 200 }}>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
@@ -307,7 +310,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
@@ -315,7 +318,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.body,
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
}}
|
||||
>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -11,22 +11,31 @@ import MoviePoster, {
|
||||
} from "@/components/posters/MoviePoster.tv";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
const ITEM_GAP = 16;
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
// TV-specific ItemCardText with larger fonts
|
||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
return (
|
||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
@@ -36,53 +45,92 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
) : item.Type === "MusicArtist" ? (
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : item.Type === "MusicAlbum" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Audio" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Playlist" ? (
|
||||
<>
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ChildCount} tracks
|
||||
</Text>
|
||||
</>
|
||||
) : item.Type === "Person" ? (
|
||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</>
|
||||
@@ -111,6 +159,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
imageUrlGetter,
|
||||
...props
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||
const [focusedCount, setFocusedCount] = useState(0);
|
||||
const prevFocusedCount = useRef(0);
|
||||
@@ -311,11 +360,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
{/* Section Header */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: "600",
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "700",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
marginLeft: SCALE_PADDING,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -20,6 +21,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||
|
||||
@@ -61,7 +63,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontSize: typography.callout,
|
||||
color: getTextColor(),
|
||||
fontWeight: isSelected || focused ? "600" : "400",
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ProgressBar } from "@/components/common/ProgressBar";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
@@ -33,6 +33,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
onBlur,
|
||||
refSetter,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const thumbnailUrl = useMemo(() => {
|
||||
@@ -67,7 +68,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
||||
|
||||
return (
|
||||
<View style={{ width: TV_EPISODE_WIDTH }}>
|
||||
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}>
|
||||
<TVFocusablePoster
|
||||
onPress={onPress}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||
@@ -112,8 +113,8 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
{episodeLabel && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: "#9CA3AF",
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
@@ -122,14 +123,10 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
)}
|
||||
{duration && (
|
||||
<>
|
||||
<Text
|
||||
style={{ color: "#6B7280", fontSize: TVTypography.callout }}
|
||||
>
|
||||
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
|
||||
•
|
||||
</Text>
|
||||
<Text
|
||||
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
|
||||
>
|
||||
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
|
||||
{duration}
|
||||
</Text>
|
||||
</>
|
||||
@@ -138,7 +135,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
marginTop: 4,
|
||||
fontWeight: "500",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Dimensions, View } from "react-native";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
|
||||
@@ -19,6 +19,7 @@ interface TVSeriesHeaderProps {
|
||||
}
|
||||
|
||||
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const api = useAtomValue(apiAtom);
|
||||
|
||||
const logoUrl = useMemo(() => {
|
||||
@@ -58,7 +59,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.display,
|
||||
fontSize: typography.display,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
@@ -80,7 +81,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||
}}
|
||||
>
|
||||
{yearString && (
|
||||
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
||||
<Text style={{ color: "white", fontSize: typography.body }}>
|
||||
{yearString}
|
||||
</Text>
|
||||
)}
|
||||
@@ -123,7 +124,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#E5E7EB",
|
||||
lineHeight: 32,
|
||||
}}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
|
||||
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
||||
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
@@ -142,6 +142,7 @@ const TVSeasonButton: React.FC<{
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}> = ({ seasonName, onPress, disabled = false }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
@@ -190,7 +191,7 @@ const TVSeasonButton: React.FC<{
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: focused ? "#000" : "#FFFFFF",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
@@ -213,6 +214,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
allEpisodes = [],
|
||||
isLoading: _isLoading,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
@@ -293,6 +295,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
return response.data.Items || [];
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline ? 60 * 1000 : undefined,
|
||||
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
||||
});
|
||||
|
||||
@@ -345,7 +348,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
});
|
||||
return res.data.Items || [];
|
||||
},
|
||||
staleTime: isOffline ? Infinity : 0,
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline ? 60 * 1000 : undefined,
|
||||
enabled: isOffline
|
||||
? !!item.Id && selectedSeasonNumber !== null
|
||||
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
@@ -565,7 +569,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
}}
|
||||
@@ -589,10 +593,10 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
<View style={{ marginTop: 40, overflow: "visible" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginBottom: 24,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
@@ -644,7 +648,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
<Text
|
||||
style={{
|
||||
color: "#737373",
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||
<Switch
|
||||
value={settings.showLargeHomeCarousel}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showLargeHomeCarousel: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||
>
|
||||
|
||||
@@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||
<Switch
|
||||
value={settings.showLargeHomeCarousel}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ showLargeHomeCarousel: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVActorCardProps {
|
||||
@@ -19,6 +19,7 @@ export interface TVActorCardProps {
|
||||
|
||||
export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
||||
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
@@ -84,7 +85,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
textAlign: "center",
|
||||
@@ -98,7 +99,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
||||
{person.Role && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVCancelButtonProps {
|
||||
@@ -16,6 +16,7 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
||||
label = "Cancel",
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||
|
||||
@@ -48,7 +49,7 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
export interface TVCastCrewTextProps {
|
||||
director?: BaseItemPerson | null;
|
||||
@@ -14,6 +14,7 @@ export interface TVCastCrewTextProps {
|
||||
|
||||
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||
({ director, cast, hideCast = false }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!director && (!cast || cast.length === 0)) {
|
||||
@@ -24,7 +25,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||
<View style={{ marginBottom: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
@@ -37,7 +38,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#6B7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
@@ -46,7 +47,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||
>
|
||||
{t("item_card.director")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{director.Name}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -55,7 +56,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#6B7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
@@ -64,7 +65,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||
>
|
||||
{t("item_card.cast")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{cast.map((c) => c.Name).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, TVFocusGuideView, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { TVActorCard } from "./TVActorCard";
|
||||
|
||||
export interface TVCastSectionProps {
|
||||
@@ -24,6 +24,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
|
||||
firstActorRefSetter,
|
||||
upwardFocusDestination,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (cast.length === 0) {
|
||||
@@ -34,7 +35,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
|
||||
<View style={{ marginBottom: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 24,
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
Pressable,
|
||||
Animated as RNAnimated,
|
||||
StyleSheet,
|
||||
Text,
|
||||
type View,
|
||||
} from "react-native";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVControlButtonProps {
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
icon?: keyof typeof Ionicons.glyphMap;
|
||||
text?: string;
|
||||
onPress: () => void;
|
||||
onLongPress?: () => void;
|
||||
onPressOut?: () => void;
|
||||
@@ -23,6 +25,7 @@ export interface TVControlButtonProps {
|
||||
|
||||
export const TVControlButton: FC<TVControlButtonProps> = ({
|
||||
icon,
|
||||
text,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressOut,
|
||||
@@ -63,7 +66,11 @@ export const TVControlButton: FC<TVControlButtonProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons name={icon} size={size} color='#fff' />
|
||||
{text ? (
|
||||
<Text style={[styles.text, { fontSize: size * 0.4 }]}>{text}</Text>
|
||||
) : (
|
||||
<Ionicons name={icon!} size={size} color='#fff' />
|
||||
)}
|
||||
</RNAnimated.View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -78,4 +85,9 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
79
components/tv/TVFilterButton.tsx
Normal file
79
components/tv/TVFilterButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVFilterButtonProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
hasActiveFilter?: boolean;
|
||||
}
|
||||
|
||||
export const TVFilterButton: React.FC<TVFilterButtonProps> = ({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
hasActiveFilter = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 });
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: focused
|
||||
? "#fff"
|
||||
: hasActiveFilter
|
||||
? "rgba(255, 255, 255, 0.25)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
borderRadius: 10,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
||||
borderColor: "rgba(255, 255, 255, 0.4)",
|
||||
}}
|
||||
>
|
||||
{label ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#444" : "#bbb",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "#FFFFFF",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
@@ -70,8 +70,8 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
|
||||
transform: [{ scale }],
|
||||
shadowColor,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
shadowOpacity: focused ? 0.3 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
|
||||
33
components/tv/TVItemCardText.tsx
Normal file
33
components/tv/TVItemCardText.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
export interface TVItemCardTextProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const TVItemCardText: React.FC<TVItemCardTextProps> = ({ item }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
return (
|
||||
<View style={{ marginTop: 12 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||
>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout - 2,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVLanguageCardProps {
|
||||
@@ -15,6 +15,8 @@ export interface TVLanguageCardProps {
|
||||
|
||||
export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
||||
({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const styles = createStyles(typography);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
@@ -72,26 +74,27 @@ export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
languageCard: {
|
||||
width: 120,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
languageCardText: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "500",
|
||||
},
|
||||
languageCardCode: {
|
||||
fontSize: TVTypography.callout,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
},
|
||||
});
|
||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
StyleSheet.create({
|
||||
languageCard: {
|
||||
width: 120,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
languageCardText: {
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
},
|
||||
languageCardCode: {
|
||||
fontSize: typography.callout,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
export interface TVMetadataBadgesProps {
|
||||
year?: number | null;
|
||||
@@ -14,6 +14,8 @@ export interface TVMetadataBadgesProps {
|
||||
|
||||
export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
||||
({ year, duration, officialRating, communityRating }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -25,12 +27,12 @@ export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
||||
}}
|
||||
>
|
||||
{year != null && (
|
||||
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
||||
<Text style={{ color: "white", fontSize: typography.body }}>
|
||||
{year}
|
||||
</Text>
|
||||
)}
|
||||
{duration && (
|
||||
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
||||
<Text style={{ color: "white", fontSize: typography.body }}>
|
||||
{duration}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { type FC, useEffect, useRef } from "react";
|
||||
import { type FC, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import Animated, {
|
||||
@@ -13,7 +13,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
export interface TVNextEpisodeCountdownProps {
|
||||
@@ -31,6 +31,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
isPlaying,
|
||||
onFinish,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
const progress = useSharedValue(0);
|
||||
const onFinishRef = useRef(onFinish);
|
||||
@@ -69,6 +70,8 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
width: `${progress.value * 100}%`,
|
||||
}));
|
||||
|
||||
const styles = useMemo(() => createStyles(typography), [typography]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
@@ -105,57 +108,58 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
bottom: 180,
|
||||
right: 80,
|
||||
zIndex: 100,
|
||||
},
|
||||
blur: {
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
innerContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
},
|
||||
thumbnail: {
|
||||
width: 180,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
justifyContent: "center",
|
||||
width: 280,
|
||||
},
|
||||
label: {
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
},
|
||||
seriesName: {
|
||||
fontSize: TVTypography.callout,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
marginBottom: 2,
|
||||
},
|
||||
episodeInfo: {
|
||||
fontSize: TVTypography.body,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginBottom: 12,
|
||||
},
|
||||
progressContainer: {
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressBar: {
|
||||
height: "100%",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 2,
|
||||
},
|
||||
});
|
||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
bottom: 180,
|
||||
right: 80,
|
||||
zIndex: 100,
|
||||
},
|
||||
blur: {
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
innerContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
},
|
||||
thumbnail: {
|
||||
width: 180,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
justifyContent: "center",
|
||||
width: 280,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
},
|
||||
seriesName: {
|
||||
fontSize: typography.callout,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
marginBottom: 2,
|
||||
},
|
||||
episodeInfo: {
|
||||
fontSize: typography.body,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginBottom: 12,
|
||||
},
|
||||
progressContainer: {
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressBar: {
|
||||
height: "100%",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: 2,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVOptionButtonProps {
|
||||
@@ -14,6 +14,7 @@ export interface TVOptionButtonProps {
|
||||
|
||||
export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||
({ label, value, onPress, hasTVPreferredFocus }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||
|
||||
@@ -50,7 +51,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#444",
|
||||
}}
|
||||
>
|
||||
@@ -58,7 +59,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#000",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
@@ -88,7 +89,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#bbb",
|
||||
}}
|
||||
>
|
||||
@@ -96,7 +97,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#E5E7EB",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVOptionCardProps {
|
||||
@@ -28,6 +28,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
@@ -59,7 +60,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || selected ? "600" : "400",
|
||||
textAlign: "center",
|
||||
@@ -71,7 +72,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
||||
{sublabel && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||
textAlign: "center",
|
||||
marginTop: 2,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { TVCancelButton } from "./TVCancelButton";
|
||||
import { TVOptionCard } from "./TVOptionCard";
|
||||
|
||||
@@ -41,6 +41,7 @@ export const TVOptionSelector = <T,>({
|
||||
cardWidth = 160,
|
||||
cardHeight = 75,
|
||||
}: TVOptionSelectorProps<T>) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
|
||||
@@ -91,6 +92,8 @@ export const TVOptionSelector = <T,>({
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
const styles = useMemo(() => createStyles(typography), [typography]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
@@ -151,50 +154,51 @@ export const TVOptionSelector = <T,>({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
title: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
StyleSheet.create({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "flex-end",
|
||||
zIndex: 1000,
|
||||
},
|
||||
sheetContainer: {
|
||||
width: "100%",
|
||||
},
|
||||
blurContainer: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
paddingTop: 24,
|
||||
paddingBottom: 50,
|
||||
overflow: "visible",
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 48,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
scrollView: {
|
||||
overflow: "visible",
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 48,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
||||
|
||||
export interface TVSeriesNavigationProps {
|
||||
@@ -16,6 +16,7 @@ export interface TVSeriesNavigationProps {
|
||||
|
||||
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
||||
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Only show for episodes with a series
|
||||
@@ -27,7 +28,7 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
||||
<View style={{ marginBottom: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 24,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Animated, Platform, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
GlassPosterView,
|
||||
isGlassEffectAvailable,
|
||||
} from "@/modules/glass-poster";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSeriesSeasonCardProps {
|
||||
@@ -21,9 +25,63 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
onPress,
|
||||
hasTVPreferredFocus,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||
|
||||
const renderPoster = () => {
|
||||
if (useGlass) {
|
||||
return (
|
||||
<GlassPosterView
|
||||
imageUrl={imageUrl}
|
||||
aspectRatio={10 / 15}
|
||||
cornerRadius={24}
|
||||
progress={0}
|
||||
showWatchedIndicator={false}
|
||||
isFocused={focused}
|
||||
width={210}
|
||||
style={{ width: 210, marginBottom: 14 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film' size={56} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -38,45 +96,16 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
width: 210,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.5 : 0,
|
||||
shadowRadius: focused ? 20 : 0,
|
||||
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
|
||||
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginBottom: 14,
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='film' size={56} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{renderPoster()}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
fontWeight: "600",
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
textAlign: "center",
|
||||
@@ -90,7 +119,7 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.8)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
@@ -23,6 +23,8 @@ export const TVSubtitleResultCard = React.forwardRef<
|
||||
View,
|
||||
TVSubtitleResultCardProps
|
||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const styles = createStyles(typography);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||
|
||||
@@ -197,72 +199,73 @@ export const TVSubtitleResultCard = React.forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
resultCard: {
|
||||
width: 220,
|
||||
minHeight: 120,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
},
|
||||
providerBadge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
providerText: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
resultName: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "500",
|
||||
marginBottom: 8,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
resultMetaText: {
|
||||
fontSize: TVTypography.callout,
|
||||
},
|
||||
ratingContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
downloadCountContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
flagsContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
flag: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
flagText: {
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
},
|
||||
downloadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
StyleSheet.create({
|
||||
resultCard: {
|
||||
width: 220,
|
||||
minHeight: 120,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
borderWidth: 1,
|
||||
},
|
||||
providerBadge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
providerText: {
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
resultName: {
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "500",
|
||||
marginBottom: 8,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
resultMetaText: {
|
||||
fontSize: typography.callout,
|
||||
},
|
||||
ratingContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
downloadCountContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
flagsContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
flag: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
flagText: {
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#fff",
|
||||
},
|
||||
downloadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVTabButtonProps {
|
||||
@@ -21,6 +21,7 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
||||
switchOnFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({
|
||||
scaleAmount: 1.05,
|
||||
@@ -56,7 +57,7 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000" : "#fff",
|
||||
fontWeight: focused || active ? "600" : "400",
|
||||
}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
export interface TVTechnicalDetailsProps {
|
||||
mediaStreams: MediaStream[];
|
||||
@@ -11,6 +11,7 @@ export interface TVTechnicalDetailsProps {
|
||||
|
||||
export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
||||
({ mediaStreams }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const videoStream = mediaStreams.find((s) => s.Type === "Video");
|
||||
@@ -24,7 +25,7 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
||||
<View style={{ marginBottom: 32 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "600",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 20,
|
||||
@@ -37,16 +38,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#6B7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Video
|
||||
{t("common.video")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{videoStream.DisplayTitle ||
|
||||
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
|
||||
</Text>
|
||||
@@ -56,16 +57,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#6B7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Audio
|
||||
{t("common.audio")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{audioStream.DisplayTitle ||
|
||||
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
|
||||
</Text>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVTrackCardProps {
|
||||
@@ -15,6 +15,8 @@ export interface TVTrackCardProps {
|
||||
|
||||
export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
||||
({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const styles = createStyles(typography);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
@@ -77,26 +79,27 @@ export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
trackCard: {
|
||||
width: 180,
|
||||
height: 80,
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
trackCardText: {
|
||||
fontSize: TVTypography.callout,
|
||||
textAlign: "center",
|
||||
},
|
||||
trackCardSublabel: {
|
||||
fontSize: TVTypography.callout,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
},
|
||||
});
|
||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||
StyleSheet.create({
|
||||
trackCard: {
|
||||
width: 180,
|
||||
height: 80,
|
||||
borderRadius: 14,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
trackCardText: {
|
||||
fontSize: typography.callout,
|
||||
textAlign: "center",
|
||||
},
|
||||
trackCardSublabel: {
|
||||
fontSize: typography.callout,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkmark: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,8 +25,12 @@ export type { TVControlButtonProps } from "./TVControlButton";
|
||||
export { TVControlButton } from "./TVControlButton";
|
||||
export type { TVFavoriteButtonProps } from "./TVFavoriteButton";
|
||||
export { TVFavoriteButton } from "./TVFavoriteButton";
|
||||
export type { TVFilterButtonProps } from "./TVFilterButton";
|
||||
export { TVFilterButton } from "./TVFilterButton";
|
||||
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
|
||||
export { TVFocusablePoster } from "./TVFocusablePoster";
|
||||
export type { TVItemCardTextProps } from "./TVItemCardText";
|
||||
export { TVItemCardText } from "./TVItemCardText";
|
||||
export type { TVLanguageCardProps } from "./TVLanguageCard";
|
||||
export { TVLanguageCard } from "./TVLanguageCard";
|
||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVLogoutButtonProps {
|
||||
@@ -15,6 +15,7 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
|
||||
disabled,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
|
||||
@@ -49,7 +50,7 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
}}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import React from "react";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
|
||||
export interface TVSectionHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#9CA3AF",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginTop: 32,
|
||||
marginBottom: 16,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => {
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
fontWeight: "600",
|
||||
color: "#9CA3AF",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
marginTop: 32,
|
||||
marginBottom: 16,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSettingsOptionButtonProps {
|
||||
@@ -20,6 +20,7 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
|
||||
isFirst,
|
||||
disabled,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
|
||||
@@ -49,13 +50,13 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginRight: 12,
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSettingsRowProps {
|
||||
@@ -22,6 +22,7 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
|
||||
showChevron = true,
|
||||
disabled,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
|
||||
@@ -51,13 +52,13 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginRight: showChevron ? 12 : 0,
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSettingsStepperProps {
|
||||
@@ -24,6 +24,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
||||
isFirst,
|
||||
disabled,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
||||
const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
||||
@@ -54,7 +55,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View style={labelAnim.animatedStyle}>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
@@ -89,7 +90,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
||||
</Pressable>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#FFFFFF",
|
||||
minWidth: 60,
|
||||
textAlign: "center",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Animated, Pressable, TextInput } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSettingsTextInputProps {
|
||||
@@ -23,6 +23,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
||||
secureTextEntry,
|
||||
disabled,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
@@ -56,7 +57,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
@@ -74,7 +75,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
fontSize: TVTypography.body,
|
||||
fontSize: typography.body,
|
||||
color: "#FFFFFF",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||
|
||||
export interface TVSettingsToggleProps {
|
||||
@@ -19,6 +19,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
|
||||
isFirst,
|
||||
disabled,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||
|
||||
@@ -48,7 +49,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<View
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, TVFocusGuideView, View } from "react-native";
|
||||
import {
|
||||
StyleSheet,
|
||||
TVFocusGuideView,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
type SharedValue,
|
||||
@@ -26,8 +31,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
|
||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
@@ -82,6 +88,96 @@ interface Props {
|
||||
const TV_SEEKBAR_HEIGHT = 14;
|
||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
||||
|
||||
// Trickplay bubble positioning constants
|
||||
const TV_TRICKPLAY_SCALE = 2;
|
||||
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
|
||||
const TV_TRICKPLAY_BUBBLE_WIDTH =
|
||||
TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE;
|
||||
const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE;
|
||||
const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE;
|
||||
const TV_TRICKPLAY_RIGHT_PADDING = 150;
|
||||
const TV_TRICKPLAY_FADE_DURATION = 200;
|
||||
|
||||
interface TVTrickplayBubbleProps {
|
||||
trickPlayUrl: {
|
||||
x: number;
|
||||
y: number;
|
||||
url: string;
|
||||
} | null;
|
||||
trickplayInfo: {
|
||||
aspectRatio?: number;
|
||||
data: {
|
||||
TileWidth?: number;
|
||||
TileHeight?: number;
|
||||
};
|
||||
} | null;
|
||||
time: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
progress: SharedValue<number>;
|
||||
max: SharedValue<number>;
|
||||
progressBarWidth: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const TVTrickplayBubblePositioned: FC<TVTrickplayBubbleProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
progress,
|
||||
max,
|
||||
progressBarWidth,
|
||||
visible,
|
||||
}) => {
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(visible ? 1 : 0, {
|
||||
duration: TV_TRICKPLAY_FADE_DURATION,
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
}, [visible, opacity]);
|
||||
|
||||
const minX = TV_TRICKPLAY_INTERNAL_OFFSET;
|
||||
const maxX =
|
||||
progressBarWidth -
|
||||
TV_TRICKPLAY_BUBBLE_WIDTH +
|
||||
TV_TRICKPLAY_INTERNAL_OFFSET +
|
||||
TV_TRICKPLAY_RIGHT_PADDING;
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const progressPercent = max.value > 0 ? progress.value / max.value : 0;
|
||||
|
||||
const xPosition = Math.max(
|
||||
minX,
|
||||
Math.min(
|
||||
maxX,
|
||||
progressPercent * progressBarWidth -
|
||||
TV_TRICKPLAY_BUBBLE_WIDTH / 2 +
|
||||
TV_TRICKPLAY_CENTERING_OFFSET,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
transform: [{ translateX: xPosition }],
|
||||
opacity: opacity.value,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.trickplayBubblePositioned, animatedStyle]}>
|
||||
<TrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={time}
|
||||
imageScale={TV_TRICKPLAY_SCALE}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
item,
|
||||
seek,
|
||||
@@ -111,8 +207,17 @@ export const Controls: FC<Props> = ({
|
||||
playMethod,
|
||||
transcodeReasons,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Calculate progress bar width (matches the padding used in bottomInner)
|
||||
const progressBarWidth = useMemo(() => {
|
||||
const leftPadding = Math.max(insets.left, 48);
|
||||
const rightPadding = Math.max(insets.right, 48);
|
||||
return screenWidth - leftPadding - rightPadding;
|
||||
}, [screenWidth, insets.left, insets.right]);
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
@@ -271,6 +376,15 @@ export const Controls: FC<Props> = ({
|
||||
isSeeking,
|
||||
});
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
item.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
_play,
|
||||
false,
|
||||
api,
|
||||
);
|
||||
|
||||
const getFinishTime = () => {
|
||||
const now = new Date();
|
||||
const finishTime = new Date(now.getTime() + remainingTime);
|
||||
@@ -831,15 +945,17 @@ export const Controls: FC<Props> = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
{showSeekBubble && (
|
||||
<View style={styles.trickplayBubbleContainer}>
|
||||
<TrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={seekBubbleTime}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.trickplayBubbleContainer}>
|
||||
<TVTrickplayBubblePositioned
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={seekBubbleTime}
|
||||
progress={effectiveProgress}
|
||||
max={max}
|
||||
progressBarWidth={progressBarWidth}
|
||||
visible={showSeekBubble}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Same padding as TVFocusableProgressBar for alignment */}
|
||||
<View style={styles.minimalProgressWrapper}>
|
||||
@@ -868,14 +984,16 @@ export const Controls: FC<Props> = ({
|
||||
</View>
|
||||
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<View style={styles.timeRight}>
|
||||
<Text style={styles.timeText}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text style={styles.endsAtText}>
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -901,12 +1019,18 @@ export const Controls: FC<Props> = ({
|
||||
<View style={styles.metadataContainer}>
|
||||
{item?.Type === "Episode" && (
|
||||
<Text
|
||||
style={styles.subtitleText}
|
||||
style={[styles.subtitleText, { fontSize: typography.body }]}
|
||||
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
||||
)}
|
||||
<Text style={styles.titleText}>{item?.Name}</Text>
|
||||
<Text style={[styles.titleText, { fontSize: typography.heading }]}>
|
||||
{item?.Name}
|
||||
</Text>
|
||||
{item?.Type === "Movie" && (
|
||||
<Text style={styles.subtitleText}>{item?.ProductionYear}</Text>
|
||||
<Text
|
||||
style={[styles.subtitleText, { fontSize: typography.body }]}
|
||||
>
|
||||
{item?.ProductionYear}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -931,6 +1055,12 @@ export const Controls: FC<Props> = ({
|
||||
disabled={false || !nextItem}
|
||||
size={28}
|
||||
/>
|
||||
<TVControlButton
|
||||
text='skip intro'
|
||||
onPress={skipIntro}
|
||||
disabled={!showSkipButton}
|
||||
size={28}
|
||||
/>
|
||||
|
||||
<View style={styles.controlButtonsSpacer} />
|
||||
|
||||
@@ -963,15 +1093,17 @@ export const Controls: FC<Props> = ({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showSeekBubble && (
|
||||
<View style={styles.trickplayBubbleContainer}>
|
||||
<TrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={seekBubbleTime}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.trickplayBubbleContainer}>
|
||||
<TVTrickplayBubblePositioned
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
time={seekBubbleTime}
|
||||
progress={effectiveProgress}
|
||||
max={max}
|
||||
progressBarWidth={progressBarWidth}
|
||||
visible={showSeekBubble}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Bidirectional focus guides - stacked together per docs */}
|
||||
{/* Downward: play button → progress bar */}
|
||||
@@ -1003,14 +1135,16 @@ export const Controls: FC<Props> = ({
|
||||
</TVFocusGuideView>
|
||||
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
{formatTimeString(currentTime, "ms")}
|
||||
</Text>
|
||||
<View style={styles.timeRight}>
|
||||
<Text style={styles.timeText}>
|
||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||
-{formatTimeString(remainingTime, "ms")}
|
||||
</Text>
|
||||
<Text style={styles.endsAtText}>
|
||||
<Text
|
||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||
>
|
||||
{t("player.ends_at")} {getFinishTime()}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -1044,11 +1178,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
subtitleText: {
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
fontSize: TVTypography.body,
|
||||
},
|
||||
titleText: {
|
||||
color: "#fff",
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
controlButtonsRow: {
|
||||
@@ -1063,12 +1195,15 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
trickplayBubbleContainer: {
|
||||
position: "absolute",
|
||||
bottom: 120,
|
||||
bottom: 170,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
zIndex: 20,
|
||||
},
|
||||
trickplayBubblePositioned: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
},
|
||||
focusGuide: {
|
||||
height: 1,
|
||||
width: "100%",
|
||||
@@ -1108,7 +1243,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
timeText: {
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: TVTypography.body,
|
||||
},
|
||||
timeRight: {
|
||||
flexDirection: "column",
|
||||
@@ -1116,7 +1250,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
endsAtText: {
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
fontSize: TVTypography.callout,
|
||||
marginTop: 2,
|
||||
},
|
||||
// Minimal seek bar styles
|
||||
|
||||
@@ -15,7 +15,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { HEADER_LAYOUT } from "./constants";
|
||||
@@ -183,6 +183,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
currentSubtitleIndex,
|
||||
currentAudioIndex,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||
@@ -277,8 +278,15 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||
};
|
||||
|
||||
const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText;
|
||||
const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText;
|
||||
const textStyle = Platform.isTV
|
||||
? [
|
||||
styles.infoTextTV,
|
||||
{ fontSize: typography.body, lineHeight: typography.body * 1.5 },
|
||||
]
|
||||
: styles.infoText;
|
||||
const reasonStyle = Platform.isTV
|
||||
? [styles.reasonTextTV, { fontSize: typography.callout }]
|
||||
: styles.reasonText;
|
||||
const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
|
||||
|
||||
return (
|
||||
@@ -383,9 +391,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
infoTextTV: {
|
||||
color: "white",
|
||||
fontSize: TVTypography.body,
|
||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||
lineHeight: TVTypography.body * 1.5,
|
||||
},
|
||||
warningText: {
|
||||
color: "#ff9800",
|
||||
@@ -396,6 +402,5 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
reasonTextTV: {
|
||||
color: "#fbbf24",
|
||||
fontSize: TVTypography.callout,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,10 @@ import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { CONTROLS_CONSTANTS } from "./constants";
|
||||
|
||||
const BASE_IMAGE_SCALE = 1.4;
|
||||
const BUBBLE_LEFT_OFFSET = 62;
|
||||
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
|
||||
|
||||
interface TrickplayBubbleProps {
|
||||
trickPlayUrl: {
|
||||
x: number;
|
||||
@@ -22,12 +26,21 @@ interface TrickplayBubbleProps {
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
};
|
||||
/** Scale factor for the image (default 1). Does not affect timestamp text. */
|
||||
imageScale?: number;
|
||||
}
|
||||
|
||||
function formatTime(hours: number, minutes: number, seconds: number): string {
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
const prefix = hours > 0 ? `${hours}:` : "";
|
||||
return `${prefix}${pad(minutes)}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
trickPlayUrl,
|
||||
trickplayInfo,
|
||||
time,
|
||||
imageScale = 1,
|
||||
}) => {
|
||||
if (!trickPlayUrl || !trickplayInfo) {
|
||||
return null;
|
||||
@@ -36,16 +49,17 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
const { x, y, url } = trickPlayUrl;
|
||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||
const finalScale = BASE_IMAGE_SCALE * imageScale;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -62,
|
||||
left: -BUBBLE_LEFT_OFFSET * imageScale,
|
||||
bottom: 0,
|
||||
paddingTop: 30,
|
||||
paddingBottom: 5,
|
||||
width: tileWidth * 1.5,
|
||||
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
@@ -55,13 +69,13 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
alignSelf: "center",
|
||||
transform: [{ scale: 1.4 }],
|
||||
transform: [{ scale: finalScale }],
|
||||
borderRadius: 5,
|
||||
}}
|
||||
className='bg-neutral-800 overflow-hidden'
|
||||
>
|
||||
<Image
|
||||
cachePolicy={"memory-disk"}
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
|
||||
height:
|
||||
@@ -76,15 +90,8 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 30,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{`${time.hours > 0 ? `${time.hours}:` : ""}${
|
||||
time.minutes < 10 ? `0${time.minutes}` : time.minutes
|
||||
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
|
||||
<Text style={{ marginTop: 30, fontSize: 16 }}>
|
||||
{formatTime(time.hours, time.minutes, time.seconds)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
/**
|
||||
* TV Typography Scale
|
||||
*
|
||||
@@ -23,3 +25,29 @@ export const TVTypography = {
|
||||
} as const;
|
||||
|
||||
export type TVTypographyKey = keyof typeof TVTypography;
|
||||
|
||||
const scaleMultipliers: Record<TVTypographyScale, number> = {
|
||||
[TVTypographyScale.Small]: 0.85,
|
||||
[TVTypographyScale.Default]: 1.0,
|
||||
[TVTypographyScale.Large]: 1.2,
|
||||
[TVTypographyScale.ExtraLarge]: 1.4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns scaled TV typography values based on user settings.
|
||||
* Use this instead of the static TVTypography constant for dynamic scaling.
|
||||
*/
|
||||
export const useScaledTVTypography = () => {
|
||||
const { settings } = useSettings();
|
||||
const scale =
|
||||
scaleMultipliers[settings.tvTypographyScale] ??
|
||||
scaleMultipliers[TVTypographyScale.Default];
|
||||
|
||||
return {
|
||||
display: Math.round(TVTypography.display * scale),
|
||||
title: Math.round(TVTypography.title * scale),
|
||||
heading: Math.round(TVTypography.heading * scale),
|
||||
body: Math.round(TVTypography.body * scale),
|
||||
callout: Math.round(TVTypography.callout * scale),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { Platform } from "react-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -49,6 +50,8 @@ export const useItemQuery = (
|
||||
return response.data.Items?.[0];
|
||||
},
|
||||
enabled: !!itemId,
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
|
||||
6
modules/glass-poster/expo-module.config.json
Normal file
6
modules/glass-poster/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["GlassPosterModule"]
|
||||
}
|
||||
}
|
||||
8
modules/glass-poster/index.ts
Normal file
8
modules/glass-poster/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Glass Poster - Native SwiftUI glass effect for tvOS 26+
|
||||
|
||||
export * from "./src/GlassPoster.types";
|
||||
export {
|
||||
default as GlassPosterModule,
|
||||
isGlassEffectAvailable,
|
||||
} from "./src/GlassPosterModule";
|
||||
export { default as GlassPosterView } from "./src/GlassPosterView";
|
||||
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'GlassPoster'
|
||||
s.version = '1.0.0'
|
||||
s.summary = 'Native SwiftUI glass effect poster for tvOS'
|
||||
s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+'
|
||||
s.author = 'Streamyfin'
|
||||
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||
s.platforms = {
|
||||
:ios => '15.1',
|
||||
:tvos => '15.1'
|
||||
}
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_VERSION' => '5.9'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,mm,swift}"
|
||||
end
|
||||
91
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
91
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import ExpoModulesCore
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
|
||||
class GlassPosterExpoView: ExpoView {
|
||||
private var hostingController: UIHostingController<GlassPosterView>?
|
||||
private var posterView: GlassPosterView
|
||||
|
||||
// Stored dimensions for intrinsic content size
|
||||
private var posterWidth: CGFloat = 260
|
||||
private var posterAspectRatio: CGFloat = 10.0 / 15.0
|
||||
|
||||
// Event dispatchers
|
||||
let onLoad = EventDispatcher()
|
||||
let onError = EventDispatcher()
|
||||
|
||||
required init(appContext: AppContext? = nil) {
|
||||
self.posterView = GlassPosterView()
|
||||
super.init(appContext: appContext)
|
||||
setupHostingController()
|
||||
}
|
||||
|
||||
private func setupHostingController() {
|
||||
let hostingController = UIHostingController(rootView: posterView)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(hostingController.view)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
self.hostingController = hostingController
|
||||
}
|
||||
|
||||
private func updateHostingController() {
|
||||
hostingController?.rootView = posterView
|
||||
}
|
||||
|
||||
// Override intrinsic content size for proper React Native layout
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let height = posterWidth / posterAspectRatio
|
||||
return CGSize(width: posterWidth, height: height)
|
||||
}
|
||||
|
||||
// MARK: - Property Setters
|
||||
|
||||
func setImageUrl(_ url: String?) {
|
||||
posterView.imageUrl = url
|
||||
updateHostingController()
|
||||
}
|
||||
|
||||
func setAspectRatio(_ ratio: Double) {
|
||||
posterView.aspectRatio = ratio
|
||||
posterAspectRatio = CGFloat(ratio)
|
||||
invalidateIntrinsicContentSize()
|
||||
updateHostingController()
|
||||
}
|
||||
|
||||
func setWidth(_ width: Double) {
|
||||
posterView.width = width
|
||||
posterWidth = CGFloat(width)
|
||||
invalidateIntrinsicContentSize()
|
||||
updateHostingController()
|
||||
}
|
||||
|
||||
func setCornerRadius(_ radius: Double) {
|
||||
posterView.cornerRadius = radius
|
||||
updateHostingController()
|
||||
}
|
||||
|
||||
func setProgress(_ progress: Double) {
|
||||
posterView.progress = progress
|
||||
updateHostingController()
|
||||
}
|
||||
|
||||
func setShowWatchedIndicator(_ show: Bool) {
|
||||
posterView.showWatchedIndicator = show
|
||||
updateHostingController()
|
||||
}
|
||||
|
||||
func setIsFocused(_ focused: Bool) {
|
||||
posterView.isFocused = focused
|
||||
updateHostingController()
|
||||
}
|
||||
}
|
||||
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class GlassPosterModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("GlassPoster")
|
||||
|
||||
// Check if glass effect is available (tvOS 26+)
|
||||
Function("isGlassEffectAvailable") { () -> Bool in
|
||||
#if os(tvOS)
|
||||
if #available(tvOS 26.0, *) {
|
||||
return true
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
// Native view component
|
||||
View(GlassPosterExpoView.self) {
|
||||
Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in
|
||||
view.setImageUrl(url)
|
||||
}
|
||||
|
||||
Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in
|
||||
view.setAspectRatio(ratio)
|
||||
}
|
||||
|
||||
Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in
|
||||
view.setCornerRadius(radius)
|
||||
}
|
||||
|
||||
Prop("progress") { (view: GlassPosterExpoView, progress: Double) in
|
||||
view.setProgress(progress)
|
||||
}
|
||||
|
||||
Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in
|
||||
view.setShowWatchedIndicator(show)
|
||||
}
|
||||
|
||||
Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in
|
||||
view.setIsFocused(focused)
|
||||
}
|
||||
|
||||
Prop("width") { (view: GlassPosterExpoView, width: Double) in
|
||||
view.setWidth(width)
|
||||
}
|
||||
|
||||
Events("onLoad", "onError")
|
||||
}
|
||||
}
|
||||
}
|
||||
195
modules/glass-poster/ios/GlassPosterView.swift
Normal file
195
modules/glass-poster/ios/GlassPosterView.swift
Normal file
@@ -0,0 +1,195 @@
|
||||
import SwiftUI
|
||||
|
||||
/// SwiftUI view with tvOS 26 Liquid Glass effect
|
||||
struct GlassPosterView: View {
|
||||
var imageUrl: String? = nil
|
||||
var aspectRatio: Double = 10.0 / 15.0
|
||||
var cornerRadius: Double = 24
|
||||
var progress: Double = 0
|
||||
var showWatchedIndicator: Bool = false
|
||||
var isFocused: Bool = false
|
||||
var width: Double = 260
|
||||
|
||||
// Internal focus state for tvOS
|
||||
@FocusState private var isInternallyFocused: Bool
|
||||
|
||||
// Combined focus state (external prop OR internal focus)
|
||||
private var isCurrentlyFocused: Bool {
|
||||
isFocused || isInternallyFocused
|
||||
}
|
||||
|
||||
// Calculated height based on width and aspect ratio
|
||||
private var height: Double {
|
||||
width / aspectRatio
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if os(tvOS)
|
||||
if #available(tvOS 26.0, *) {
|
||||
glassContent
|
||||
} else {
|
||||
fallbackContent
|
||||
}
|
||||
#else
|
||||
fallbackContent
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - tvOS 26+ Glass Effect
|
||||
|
||||
#if os(tvOS)
|
||||
@available(tvOS 26.0, *)
|
||||
private var glassContent: some View {
|
||||
return ZStack {
|
||||
// Image content
|
||||
imageContent
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
|
||||
// Progress bar overlay
|
||||
if progress > 0 {
|
||||
progressOverlay
|
||||
}
|
||||
|
||||
// Watched indicator
|
||||
if showWatchedIndicator {
|
||||
watchedIndicatorOverlay
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.focusable()
|
||||
.focused($isInternallyFocused)
|
||||
.scaleEffect(isCurrentlyFocused ? 1.08 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Fallback for older tvOS versions
|
||||
|
||||
private var fallbackContent: some View {
|
||||
ZStack {
|
||||
// Main image
|
||||
imageContent
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
|
||||
// Subtle overlay for depth
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(.ultraThinMaterial.opacity(0.15))
|
||||
|
||||
// Progress bar overlay
|
||||
if progress > 0 {
|
||||
progressOverlay
|
||||
}
|
||||
|
||||
// Watched indicator
|
||||
if showWatchedIndicator {
|
||||
watchedIndicatorOverlay
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.scaleEffect(isFocused ? 1.08 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: isFocused)
|
||||
}
|
||||
|
||||
// MARK: - Shared Components
|
||||
|
||||
private var imageContent: some View {
|
||||
Group {
|
||||
if let urlString = imageUrl, let url = URL(string: urlString) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
placeholderView
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
placeholderView
|
||||
@unknown default:
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
} else {
|
||||
placeholderView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderView: some View {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
}
|
||||
|
||||
private var progressOverlay: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
// Background track
|
||||
Rectangle()
|
||||
.fill(Color.white.opacity(0.3))
|
||||
.frame(height: 4)
|
||||
|
||||
// Progress fill
|
||||
Rectangle()
|
||||
.fill(Color.white)
|
||||
.frame(width: geometry.size.width * CGFloat(progress / 100), height: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
private var watchedIndicatorOverlay: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.9))
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#if DEBUG
|
||||
struct GlassPosterView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 20) {
|
||||
GlassPosterView(
|
||||
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
|
||||
aspectRatio: 10.0 / 15.0,
|
||||
cornerRadius: 24,
|
||||
progress: 45,
|
||||
showWatchedIndicator: false,
|
||||
isFocused: true,
|
||||
width: 260
|
||||
)
|
||||
|
||||
GlassPosterView(
|
||||
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
|
||||
aspectRatio: 16.0 / 9.0,
|
||||
cornerRadius: 24,
|
||||
progress: 75,
|
||||
showWatchedIndicator: true,
|
||||
isFocused: false,
|
||||
width: 400
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
export interface GlassPosterViewProps {
|
||||
/** URL of the image to display */
|
||||
imageUrl: string | null;
|
||||
/** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */
|
||||
aspectRatio: number;
|
||||
/** Corner radius in points. Default: 24 */
|
||||
cornerRadius: number;
|
||||
/** Progress percentage (0-100). Shows progress bar at bottom when > 0 */
|
||||
progress: number;
|
||||
/** Whether to show the watched checkmark indicator */
|
||||
showWatchedIndicator: boolean;
|
||||
/** Whether the poster is currently focused (for scale animation) */
|
||||
isFocused: boolean;
|
||||
/** Width of the poster in points. Required for proper sizing. */
|
||||
width: number;
|
||||
/** Style for the container view */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Called when the image loads successfully */
|
||||
onLoad?: () => void;
|
||||
/** Called when image loading fails */
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export type GlassPosterModuleEvents = Record<string, never>;
|
||||
36
modules/glass-poster/src/GlassPosterModule.ts
Normal file
36
modules/glass-poster/src/GlassPosterModule.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NativeModule, requireNativeModule } from "expo";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import type { GlassPosterModuleEvents } from "./GlassPoster.types";
|
||||
|
||||
declare class GlassPosterModuleType extends NativeModule<GlassPosterModuleEvents> {
|
||||
isGlassEffectAvailable(): boolean;
|
||||
}
|
||||
|
||||
// Only load the native module on tvOS
|
||||
let GlassPosterNativeModule: GlassPosterModuleType | null = null;
|
||||
|
||||
if (Platform.OS === "ios" && Platform.isTV) {
|
||||
try {
|
||||
GlassPosterNativeModule =
|
||||
requireNativeModule<GlassPosterModuleType>("GlassPoster");
|
||||
} catch {
|
||||
// Module not available, will use fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the native glass effect is available (tvOS 26+)
|
||||
*/
|
||||
export function isGlassEffectAvailable(): boolean {
|
||||
if (!GlassPosterNativeModule) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return GlassPosterNativeModule.isGlassEffectAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default GlassPosterNativeModule;
|
||||
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { requireNativeView } from "expo";
|
||||
import * as React from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
import type { GlassPosterViewProps } from "./GlassPoster.types";
|
||||
import { isGlassEffectAvailable } from "./GlassPosterModule";
|
||||
|
||||
// Only require the native view on tvOS
|
||||
let NativeGlassPosterView: React.ComponentType<GlassPosterViewProps> | null =
|
||||
null;
|
||||
|
||||
if (Platform.OS === "ios" && Platform.isTV) {
|
||||
try {
|
||||
NativeGlassPosterView =
|
||||
requireNativeView<GlassPosterViewProps>("GlassPoster");
|
||||
} catch {
|
||||
// Module not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+
|
||||
*
|
||||
* On tvOS 26+: Renders with native Liquid Glass effect
|
||||
* On older tvOS: Renders with subtle glass-like material effect
|
||||
* On other platforms: Returns null (use existing poster components)
|
||||
*/
|
||||
const GlassPosterView: React.FC<GlassPosterViewProps> = (props) => {
|
||||
// Only render on tvOS
|
||||
if (!Platform.isTV || Platform.OS !== "ios") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use native view if available
|
||||
if (NativeGlassPosterView) {
|
||||
return <NativeGlassPosterView {...props} />;
|
||||
}
|
||||
|
||||
// Fallback: return empty view (caller should handle this)
|
||||
return <View style={props.style} />;
|
||||
};
|
||||
|
||||
export default GlassPosterView;
|
||||
|
||||
// Re-export availability check for convenience
|
||||
export { isGlassEffectAvailable };
|
||||
6
modules/glass-poster/src/index.ts
Normal file
6
modules/glass-poster/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./GlassPoster.types";
|
||||
export {
|
||||
default as GlassPosterModule,
|
||||
isGlassEffectAvailable,
|
||||
} from "./GlassPosterModule";
|
||||
export { default as GlassPosterView } from "./GlassPosterView";
|
||||
@@ -7,7 +7,9 @@ export type {
|
||||
DownloadStartedEvent,
|
||||
} from "./background-downloader";
|
||||
export { default as BackgroundDownloader } from "./background-downloader";
|
||||
|
||||
// Glass Poster (tvOS 26+)
|
||||
export type { GlassPosterViewProps } from "./glass-poster";
|
||||
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
|
||||
// MPV Player (iOS + Android)
|
||||
export type {
|
||||
AudioTrack as MpvAudioTrack,
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"react-native-pager-view": "^6.9.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-reanimated-carousel": "4.0.3",
|
||||
"react-native-responsive-sizes": "^2.1.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.18.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user