mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-15 22:06:17 +00:00
Compare commits
1 Commits
feat/tv-in
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c21054d33 |
@@ -30,14 +30,4 @@ 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)_
|
- **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,6 +1,15 @@
|
|||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Home } from "../../../../components/home/Home";
|
import { Home } from "../../../../components/home/Home";
|
||||||
|
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
|
||||||
|
|
||||||
|
if (showLargeHomeCarousel) {
|
||||||
|
return <HomeWithCarousel />;
|
||||||
|
}
|
||||||
|
|
||||||
return <Home />;
|
return <Home />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,9 @@ import {
|
|||||||
TVSettingsTextInput,
|
TVSettingsTextInput,
|
||||||
TVSettingsToggle,
|
TVSettingsToggle,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||||
AudioTranscodeMode,
|
|
||||||
TVTypographyScale,
|
|
||||||
useSettings,
|
|
||||||
} from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,7 +27,6 @@ export default function SettingsTV() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { showOptions } = useTVOptionModal();
|
const { showOptions } = useTVOptionModal();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
// Local state for OpenSubtitles API key (only commit on blur)
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
@@ -45,8 +39,6 @@ export default function SettingsTV() {
|
|||||||
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
settings.subtitleMode || SubtitlePlaybackMode.Default;
|
||||||
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
|
||||||
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
|
||||||
const currentTypographyScale =
|
|
||||||
settings.tvTypographyScale || TVTypographyScale.Default;
|
|
||||||
|
|
||||||
// Audio transcoding options
|
// Audio transcoding options
|
||||||
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
|
||||||
@@ -138,33 +130,6 @@ export default function SettingsTV() {
|
|||||||
[currentAlignY],
|
[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
|
// Get display labels for option buttons
|
||||||
const audioTranscodeLabel = useMemo(() => {
|
const audioTranscodeLabel = useMemo(() => {
|
||||||
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
const option = audioTranscodeModeOptions.find((o) => o.selected);
|
||||||
@@ -186,11 +151,6 @@ export default function SettingsTV() {
|
|||||||
return option?.label || "Bottom";
|
return option?.label || "Bottom";
|
||||||
}, [alignYOptions]);
|
}, [alignYOptions]);
|
||||||
|
|
||||||
const typographyScaleLabel = useMemo(() => {
|
|
||||||
const option = typographyScaleOptions.find((o) => o.selected);
|
|
||||||
return option?.label || t("home.settings.appearance.text_size_default");
|
|
||||||
}, [typographyScaleOptions, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
@@ -206,7 +166,7 @@ export default function SettingsTV() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.title,
|
fontSize: 42,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -349,7 +309,7 @@ export default function SettingsTV() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
fontSize: typography.callout - 2,
|
fontSize: 14,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -373,7 +333,7 @@ export default function SettingsTV() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
fontSize: typography.callout - 4,
|
fontSize: 12,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -384,18 +344,6 @@ export default function SettingsTV() {
|
|||||||
|
|
||||||
{/* Appearance Section */}
|
{/* Appearance Section */}
|
||||||
<TVSectionHeader title={t("home.settings.appearance.title")} />
|
<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
|
<TVSettingsToggle
|
||||||
label={t(
|
label={t(
|
||||||
"home.settings.appearance.merge_next_up_continue_watching",
|
"home.settings.appearance.merge_next_up_continue_watching",
|
||||||
@@ -410,18 +358,6 @@ export default function SettingsTV() {
|
|||||||
value={settings.showHomeBackdrop}
|
value={settings.showHomeBackdrop}
|
||||||
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
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 */}
|
{/* User Section */}
|
||||||
<TVSectionHeader
|
<TVSectionHeader
|
||||||
|
|||||||
@@ -15,29 +15,14 @@ import { useAtom } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import {
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
getItemNavigation,
|
|
||||||
TouchableItemRouter,
|
|
||||||
} from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
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 * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -51,10 +36,6 @@ import {
|
|||||||
tagsFilterAtom,
|
tagsFilterAtom,
|
||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} 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 page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -63,15 +44,11 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const router = useRouter();
|
|
||||||
const { showOptions } = useTVOptionModal();
|
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
|
||||||
const [orientation, _setOrientation] = useState(
|
const [orientation, _setOrientation] = useState(
|
||||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
@@ -79,7 +56,7 @@ const page: React.FC = () => {
|
|||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||||
|
|
||||||
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
const { data: collection } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -94,46 +71,6 @@ const page: React.FC = () => {
|
|||||||
staleTime: 60 * 1000,
|
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(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({ title: collection?.Name || "" });
|
navigation.setOptions({ title: collection?.Name || "" });
|
||||||
setSortOrder([SortOrderOption.Ascending]);
|
setSortOrder([SortOrderOption.Ascending]);
|
||||||
@@ -150,18 +87,6 @@ const page: React.FC = () => {
|
|||||||
setSortBy([sortByOption]);
|
setSortBy([sortByOption]);
|
||||||
}, [navigation, collection]);
|
}, [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(
|
const fetchItems = useCallback(
|
||||||
async ({
|
async ({
|
||||||
pageParam,
|
pageParam,
|
||||||
@@ -173,7 +98,7 @@ const page: React.FC = () => {
|
|||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
limit: Platform.isTV ? 36 : 18,
|
limit: 18,
|
||||||
startIndex: pageParam,
|
startIndex: pageParam,
|
||||||
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
// Set one ordering at a time. As collections do not work with correctly with multiple.
|
||||||
sortBy: [sortBy[0]],
|
sortBy: [sortBy[0]],
|
||||||
@@ -198,7 +123,6 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collection,
|
collection,
|
||||||
collectionId,
|
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -207,40 +131,39 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
useInfiniteQuery({
|
queryKey: [
|
||||||
queryKey: [
|
"collection-items",
|
||||||
"collection-items",
|
collection,
|
||||||
collectionId,
|
selectedGenres,
|
||||||
selectedGenres,
|
selectedYears,
|
||||||
selectedYears,
|
selectedTags,
|
||||||
selectedTags,
|
sortBy,
|
||||||
sortBy,
|
sortOrder,
|
||||||
sortOrder,
|
],
|
||||||
],
|
queryFn: fetchItems,
|
||||||
queryFn: fetchItems,
|
getNextPageParam: (lastPage, pages) => {
|
||||||
getNextPageParam: (lastPage, pages) => {
|
if (
|
||||||
if (
|
!lastPage?.Items ||
|
||||||
!lastPage?.Items ||
|
!lastPage?.TotalRecordCount ||
|
||||||
!lastPage?.TotalRecordCount ||
|
lastPage?.TotalRecordCount === 0
|
||||||
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;
|
return undefined;
|
||||||
},
|
|
||||||
initialPageParam: 0,
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -272,6 +195,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
|
{/* <MoviePoster item={item} /> */}
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
@@ -279,39 +203,10 @@ const page: React.FC = () => {
|
|||||||
[orientation],
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
|
||||||
({ item }: { item: BaseItemDto }) => {
|
|
||||||
const handlePress = () => {
|
|
||||||
const navTarget = getItemNavigation(item, "(home)");
|
|
||||||
router.push(navTarget as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -477,315 +372,48 @@ 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;
|
if (!collection) return null;
|
||||||
|
|
||||||
// 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>
|
|
||||||
}
|
|
||||||
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 (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<FlashList
|
||||||
{/* Filter bar */}
|
ListEmptyComponent={
|
||||||
<View
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
style={{
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
flexDirection: "row",
|
{t("search.no_results")}
|
||||||
flexWrap: "nowrap",
|
</Text>
|
||||||
marginTop: insets.top + 100,
|
</View>
|
||||||
paddingBottom: 8,
|
}
|
||||||
paddingHorizontal: TV_SCALE_PADDING,
|
extraData={[
|
||||||
gap: 12,
|
selectedGenres,
|
||||||
}}
|
selectedYears,
|
||||||
>
|
selectedTags,
|
||||||
{hasActiveFilters && (
|
sortBy,
|
||||||
<TVFilterButton
|
sortOrder,
|
||||||
label=''
|
]}
|
||||||
value={t("library.filters.reset")}
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
onPress={resetAllFilters}
|
data={flatData}
|
||||||
hasActiveFilter
|
renderItem={renderItem}
|
||||||
/>
|
keyExtractor={keyExtractor}
|
||||||
)}
|
numColumns={
|
||||||
<TVFilterButton
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
label={t("library.filters.genres")}
|
}
|
||||||
value={
|
onEndReached={() => {
|
||||||
selectedGenres.length > 0
|
if (hasNextPage) {
|
||||||
? `${selectedGenres.length} selected`
|
fetchNextPage();
|
||||||
: 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}
|
onEndReachedThreshold={0.5}
|
||||||
renderItem={renderTVItem}
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
extraData={[orientation, nrOfCols]}
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
keyExtractor={keyExtractor}
|
ItemSeparatorComponent={() => (
|
||||||
numColumns={nrOfCols}
|
<View
|
||||||
removeClippedSubviews={false}
|
style={{
|
||||||
onEndReached={() => {
|
width: 10,
|
||||||
if (hasNextPage) {
|
height: 10,
|
||||||
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,7 +62,6 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,8 +117,7 @@ const page: React.FC = () => {
|
|||||||
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
),
|
),
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60,
|
||||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -11,13 +12,23 @@ import {
|
|||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
FlatList,
|
FlatList,
|
||||||
Platform,
|
Platform,
|
||||||
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -37,15 +48,9 @@ import MoviePoster, {
|
|||||||
TV_POSTER_WIDTH,
|
TV_POSTER_WIDTH,
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
import {
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
TVFilterButton,
|
|
||||||
TVFocusablePoster,
|
|
||||||
TVItemCardText,
|
|
||||||
} from "@/components/tv";
|
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
|
||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import {
|
import {
|
||||||
@@ -69,11 +74,280 @@ import {
|
|||||||
yearFilterAtom,
|
yearFilterAtom,
|
||||||
} from "@/utils/atoms/filters";
|
} from "@/utils/atoms/filters";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
|
|
||||||
|
|
||||||
const TV_ITEM_GAP = 20;
|
const TV_ITEM_GAP = 16;
|
||||||
const TV_HORIZONTAL_PADDING = 60;
|
const TV_SCALE_PADDING = 20;
|
||||||
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 Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams() as {
|
const searchParams = useLocalSearchParams() as {
|
||||||
@@ -84,7 +358,6 @@ const Page = () => {
|
|||||||
};
|
};
|
||||||
const { libraryId } = searchParams;
|
const { libraryId } = searchParams;
|
||||||
|
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
const { width: screenWidth } = useWindowDimensions();
|
||||||
@@ -107,7 +380,13 @@ const Page = () => {
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showOptions } = useTVOptionModal();
|
|
||||||
|
// TV Filter modal state
|
||||||
|
const [openFilterModal, setOpenFilterModal] =
|
||||||
|
useState<TVFilterModalType>(null);
|
||||||
|
const isFilterModalOpen = openFilterModal !== null;
|
||||||
|
|
||||||
|
const isFiltersDisabled = isFilterModalOpen;
|
||||||
|
|
||||||
// TV Filter queries
|
// TV Filter queries
|
||||||
const { data: tvGenreOptions } = useQuery({
|
const { data: tvGenreOptions } = useQuery({
|
||||||
@@ -232,8 +511,12 @@ const Page = () => {
|
|||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
// Calculate columns based on TV poster width + gap
|
||||||
return 1;
|
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
@@ -399,7 +682,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
(item: BaseItemDto) => {
|
({ item }: { item: BaseItemDto }) => {
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
const navTarget = getItemNavigation(item, "(libraries)");
|
const navTarget = getItemNavigation(item, "(libraries)");
|
||||||
router.push(navTarget as any);
|
router.push(navTarget as any);
|
||||||
@@ -407,12 +690,13 @@ const Page = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={item.Id}
|
|
||||||
style={{
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
width: TV_POSTER_WIDTH,
|
width: TV_POSTER_WIDTH,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVFocusablePoster onPress={handlePress}>
|
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
{(item.Type === "Series" || item.Type === "Episode") && (
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
@@ -425,7 +709,7 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router],
|
[router, isFilterModalOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -628,7 +912,7 @@ const Page = () => {
|
|||||||
|
|
||||||
// TV Filter options - with "All" option for clearable filters
|
// TV Filter options - with "All" option for clearable filters
|
||||||
const tvGenreFilterOptions = useMemo(
|
const tvGenreFilterOptions = useMemo(
|
||||||
(): TVOptionItem<string>[] => [
|
(): TVFilterOption<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -644,7 +928,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvYearFilterOptions = useMemo(
|
const tvYearFilterOptions = useMemo(
|
||||||
(): TVOptionItem<string>[] => [
|
(): TVFilterOption<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -660,7 +944,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvTagFilterOptions = useMemo(
|
const tvTagFilterOptions = useMemo(
|
||||||
(): TVOptionItem<string>[] => [
|
(): TVFilterOption<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -676,7 +960,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvSortByOptions = useMemo(
|
const tvSortByOptions = useMemo(
|
||||||
(): TVOptionItem<SortByOption>[] =>
|
(): TVFilterOption<SortByOption>[] =>
|
||||||
sortOptions.map((option) => ({
|
sortOptions.map((option) => ({
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.key,
|
value: option.key,
|
||||||
@@ -686,7 +970,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvSortOrderOptions = useMemo(
|
const tvSortOrderOptions = useMemo(
|
||||||
(): TVOptionItem<SortOrderOption>[] =>
|
(): TVFilterOption<SortOrderOption>[] =>
|
||||||
sortOrderOptions.map((option) => ({
|
sortOrderOptions.map((option) => ({
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.key,
|
value: option.key,
|
||||||
@@ -696,7 +980,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvFilterByOptions = useMemo(
|
const tvFilterByOptions = useMemo(
|
||||||
(): TVOptionItem<string>[] => [
|
(): TVFilterOption<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -711,88 +995,56 @@ const Page = () => {
|
|||||||
[filterBy, generalFilters, t],
|
[filterBy, generalFilters, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV Filter handlers using navigation-based modal
|
// TV Filter handlers
|
||||||
const handleShowGenreFilter = useCallback(() => {
|
const handleGenreSelect = useCallback(
|
||||||
showOptions({
|
(value: string) => {
|
||||||
title: t("library.filters.genres"),
|
if (value === "__all__") {
|
||||||
options: tvGenreFilterOptions,
|
setSelectedGenres([]);
|
||||||
onSelect: (value: string) => {
|
} else if (selectedGenres.includes(value)) {
|
||||||
if (value === "__all__") {
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
setSelectedGenres([]);
|
} else {
|
||||||
} else if (selectedGenres.includes(value)) {
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
}
|
||||||
} else {
|
},
|
||||||
setSelectedGenres([...selectedGenres, value]);
|
[selectedGenres, setSelectedGenres],
|
||||||
}
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
|
||||||
|
|
||||||
const handleShowYearFilter = useCallback(() => {
|
const handleYearSelect = useCallback(
|
||||||
showOptions({
|
(value: string) => {
|
||||||
title: t("library.filters.years"),
|
if (value === "__all__") {
|
||||||
options: tvYearFilterOptions,
|
setSelectedYears([]);
|
||||||
onSelect: (value: string) => {
|
} else if (selectedYears.includes(value)) {
|
||||||
if (value === "__all__") {
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
setSelectedYears([]);
|
} else {
|
||||||
} else if (selectedYears.includes(value)) {
|
setSelectedYears([...selectedYears, value]);
|
||||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
}
|
||||||
} else {
|
},
|
||||||
setSelectedYears([...selectedYears, value]);
|
[selectedYears, setSelectedYears],
|
||||||
}
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
|
||||||
|
|
||||||
const handleShowTagFilter = useCallback(() => {
|
const handleTagSelect = useCallback(
|
||||||
showOptions({
|
(value: string) => {
|
||||||
title: t("library.filters.tags"),
|
if (value === "__all__") {
|
||||||
options: tvTagFilterOptions,
|
setSelectedTags([]);
|
||||||
onSelect: (value: string) => {
|
} else if (selectedTags.includes(value)) {
|
||||||
if (value === "__all__") {
|
setSelectedTags(selectedTags.filter((t) => t !== value));
|
||||||
setSelectedTags([]);
|
} else {
|
||||||
} else if (selectedTags.includes(value)) {
|
setSelectedTags([...selectedTags, value]);
|
||||||
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
}
|
||||||
} else {
|
},
|
||||||
setSelectedTags([...selectedTags, value]);
|
[selectedTags, setSelectedTags],
|
||||||
}
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
|
||||||
|
|
||||||
const handleShowSortByFilter = useCallback(() => {
|
const handleFilterBySelect = useCallback(
|
||||||
showOptions({
|
(value: string) => {
|
||||||
title: t("library.filters.sort_by"),
|
if (value === "__all__") {
|
||||||
options: tvSortByOptions,
|
_setFilterBy([]);
|
||||||
onSelect: (value: SortByOption) => {
|
} else {
|
||||||
setSortBy([value]);
|
setFilter([value as FilterByOption]);
|
||||||
},
|
}
|
||||||
});
|
},
|
||||||
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
[setFilter, _setFilterBy],
|
||||||
|
);
|
||||||
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();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -845,134 +1097,185 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TV return with filter bar
|
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View style={{ flex: 1 }}>
|
||||||
style={{ flex: 1 }}
|
{/* Background content - disabled when modal is open */}
|
||||||
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
|
<View
|
||||||
style={{
|
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
|
||||||
flexDirection: "row",
|
focusable={!isFilterModalOpen}
|
||||||
flexWrap: "nowrap",
|
isTVSelectable={!isFilterModalOpen}
|
||||||
justifyContent: "center",
|
pointerEvents={isFilterModalOpen ? "none" : "auto"}
|
||||||
paddingBottom: 24,
|
accessibilityElementsHidden={isFilterModalOpen}
|
||||||
gap: 12,
|
importantForAccessibility={
|
||||||
}}
|
isFilterModalOpen ? "no-hide-descendants" : "auto"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{hasActiveFilters && (
|
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
flexWrap: "nowrap",
|
||||||
justifyContent: "center",
|
marginTop: insets.top + 100,
|
||||||
gap: TV_ITEM_GAP,
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flatData.map((item) => renderTVItem(item))}
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
disabled={isFiltersDisabled}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<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>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading indicator */}
|
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
|
||||||
{isFetching && (
|
<FlatList
|
||||||
<View style={{ paddingVertical: 20 }}>
|
key={`${orientation}-${nrOfCols}`}
|
||||||
<Loader />
|
ListEmptyComponent={
|
||||||
</View>
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
)}
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
</ScrollView>
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -29,7 +28,6 @@ import MoviePoster, {
|
|||||||
} from "@/components/posters/MoviePoster.tv";
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import {
|
import {
|
||||||
@@ -43,36 +41,21 @@ import {
|
|||||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
import { userAtom } from "@/providers/JellyfinProvider";
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const TV_ITEM_GAP = 20;
|
const TV_ITEM_GAP = 16;
|
||||||
const TV_HORIZONTAL_PADDING = 60;
|
const TV_SCALE_PADDING = 20;
|
||||||
|
|
||||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{
|
|
||||||
item: BaseItemDto;
|
|
||||||
typography: Typography;
|
|
||||||
}> = ({ item, typography }) => (
|
|
||||||
<View style={{ marginTop: 12 }}>
|
<View style={{ marginTop: 12 }}>
|
||||||
<Text
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
style={{
|
|
||||||
fontSize: typography.callout - 2,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function WatchlistDetailScreen() {
|
export default function WatchlistDetailScreen() {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -87,8 +70,14 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
// TV uses flexWrap, so nrOfCols is just for mobile
|
if (Platform.isTV) {
|
||||||
if (Platform.isTV) return 1;
|
// 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (screenWidth < 300) return 2;
|
if (screenWidth < 300) return 2;
|
||||||
if (screenWidth < 500) return 3;
|
if (screenWidth < 500) return 3;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -196,7 +185,7 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
(item: BaseItemDto, index: number) => {
|
({ item, index }: { item: BaseItemDto; index: number }) => {
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
const navigation = getItemNavigation(item, "(watchlists)");
|
const navigation = getItemNavigation(item, "(watchlists)");
|
||||||
router.push(navigation as any);
|
router.push(navigation as any);
|
||||||
@@ -204,8 +193,9 @@ export default function WatchlistDetailScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={item.Id}
|
|
||||||
style={{
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
width: TV_POSTER_WIDTH,
|
width: TV_POSTER_WIDTH,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -218,11 +208,11 @@ export default function WatchlistDetailScreen() {
|
|||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
)}
|
)}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} typography={typography} />
|
<TVItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router, typography],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -338,120 +328,6 @@ 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 (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
@@ -464,13 +340,14 @@ export default function WatchlistDetailScreen() {
|
|||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
paddingLeft: insets.left,
|
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
|
||||||
|
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
}
|
}
|
||||||
renderItem={renderItem}
|
renderItem={Platform.isTV ? renderTVItem : renderItem}
|
||||||
ItemSeparatorComponent={() => (
|
ItemSeparatorComponent={() => (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRo
|
|||||||
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||||
@@ -30,7 +30,6 @@ import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/
|
|||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
export default function TVRequestModalPage() {
|
export default function TVRequestModalPage() {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvRequestModalAtom);
|
const modalState = useAtomValue(tvRequestModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -337,12 +336,8 @@ export default function TVRequestModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
|
||||||
{t("jellyseerr.advanced")}
|
<Text style={styles.subtitle}>{modalState.title}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
|
||||||
{modalState.title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{isDataLoaded && isReady ? (
|
{isDataLoaded && isReady ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -395,12 +390,7 @@ export default function TVRequestModalPage() {
|
|||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text style={styles.buttonText}>
|
||||||
style={[
|
|
||||||
styles.buttonText,
|
|
||||||
{ fontSize: typography.callout },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
@@ -461,11 +451,13 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
@@ -490,6 +482,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVButton } from "@/components/tv";
|
import { TVButton } from "@/components/tv";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
@@ -162,7 +162,6 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TVSeasonSelectModalPage() {
|
export default function TVSeasonSelectModalPage() {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
const modalState = useAtomValue(tvSeasonSelectModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -306,12 +305,8 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
|
||||||
{t("jellyseerr.select_seasons")}
|
<Text style={styles.subtitle}>{modalState.title}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
|
||||||
{modalState.title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Season cards horizontal scroll */}
|
{/* Season cards horizontal scroll */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -348,9 +343,7 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text style={styles.buttonText}>
|
||||||
style={[styles.buttonText, { fontSize: typography.callout }]}
|
|
||||||
>
|
|
||||||
{t("jellyseerr.request_selected")}
|
{t("jellyseerr.request_selected")}
|
||||||
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -384,11 +377,13 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
@@ -418,6 +413,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
seasonTitle: {
|
seasonTitle: {
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
@@ -440,6 +436,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
import { TVCancelButton, TVOptionCard } from "@/components/tv";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||||
import { store } from "@/utils/store";
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
export default function TVSeriesSeasonModalPage() {
|
export default function TVSeriesSeasonModalPage() {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -104,9 +103,7 @@ export default function TVSeriesSeasonModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
<Text style={styles.title}>{t("item_card.select_season")}</Text>
|
||||||
{t("item_card.select_season")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -167,6 +164,7 @@ const styles = StyleSheet.create({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
110
bun.lock
110
bun.lock
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -78,6 +77,7 @@
|
|||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-reanimated-carousel": "4.0.3",
|
"react-native-reanimated-carousel": "4.0.3",
|
||||||
|
"react-native-responsive-sizes": "^2.1.0",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.18.0",
|
"react-native-screens": "~4.18.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
@@ -562,6 +562,8 @@
|
|||||||
|
|
||||||
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
|
"@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/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=="],
|
"@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=="],
|
||||||
@@ -802,6 +804,10 @@
|
|||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"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=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||||
@@ -1240,6 +1246,8 @@
|
|||||||
|
|
||||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
"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-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
@@ -1318,6 +1326,8 @@
|
|||||||
|
|
||||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
"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-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=="],
|
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||||
@@ -1686,6 +1696,8 @@
|
|||||||
|
|
||||||
"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-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-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=="],
|
"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=="],
|
||||||
@@ -2280,6 +2292,8 @@
|
|||||||
|
|
||||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
"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=="],
|
"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=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
@@ -2432,6 +2446,8 @@
|
|||||||
|
|
||||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"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/@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=="],
|
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
|
||||||
@@ -2978,6 +2994,30 @@
|
|||||||
|
|
||||||
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"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=="],
|
"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=="],
|
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
@@ -3162,6 +3202,26 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
@@ -3198,6 +3258,42 @@
|
|||||||
|
|
||||||
"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=="],
|
"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/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=="],
|
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
|
||||||
@@ -3210,6 +3306,18 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"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=="],
|
"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 { BlurView } from "expo-blur";
|
||||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -16,8 +16,6 @@ export const Badge: React.FC<Props> = ({
|
|||||||
variant = "purple",
|
variant = "purple",
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||||
@@ -71,7 +69,7 @@ export const Badge: React.FC<Props> = ({
|
|||||||
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { fontSize, size } from "react-native-responsive-sizes";
|
||||||
import { useHaptic } from "@/hooks/useHaptic";
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
|
|
||||||
@@ -140,11 +141,15 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl py-5 items-center justify-center
|
className={`rounded-2xl items-center justify-center
|
||||||
${colorClasses}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
|
style={{ paddingVertical: size(50) }}
|
||||||
>
|
>
|
||||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
<Text
|
||||||
|
className={`${textColorClass} font-bold`}
|
||||||
|
style={{ fontSize: fontSize(10) }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ import { useAtomValue } from "jotai";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import {
|
|
||||||
GlassPosterView,
|
|
||||||
isGlassEffectAvailable,
|
|
||||||
} from "@/modules/glass-poster";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { ProgressBar } from "./common/ProgressBar";
|
import { ProgressBar } from "./common/ProgressBar";
|
||||||
import { WatchedIndicator } from "./WatchedIndicator";
|
import { WatchedIndicator } from "./WatchedIndicator";
|
||||||
|
|
||||||
export const TV_LANDSCAPE_WIDTH = 400;
|
export const TV_LANDSCAPE_WIDTH = 340;
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -36,57 +32,34 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode" && useEpisodePoster) {
|
if (item.Type === "Episode" && useEpisodePoster) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Movie") {
|
if (item.Type === "Movie") {
|
||||||
if (item.ImageTags?.Thumb) {
|
if (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/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
}
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Program") {
|
if (item.Type === "Program") {
|
||||||
if (item.ImageTags?.Thumb) {
|
if (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/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
}
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.ImageTags?.Thumb) {
|
if (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/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
||||||
}, [api, item, useEpisodePoster]);
|
}, [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) {
|
if (!url) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -99,39 +72,6 @@ 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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
type ViewProps,
|
type ViewProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -25,9 +25,6 @@ export const Tag: React.FC<
|
|||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
} & ViewProps
|
} & ViewProps
|
||||||
> = ({ text, textClass, textStyle, ...props }) => {
|
> = ({ 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) {
|
if (Platform.OS === "ios" && !Platform.isTV) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -63,7 +60,7 @@ export const Tag: React.FC<
|
|||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
|
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
MediaStream,
|
MediaStream,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
@@ -17,14 +17,12 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, ScrollView, View } from "react-native";
|
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
|
||||||
import {
|
import {
|
||||||
TVBackdrop,
|
TVBackdrop,
|
||||||
TVButton,
|
TVButton,
|
||||||
@@ -39,7 +37,7 @@ import {
|
|||||||
TVTechnicalDetails,
|
TVTechnicalDetails,
|
||||||
} from "@/components/tv";
|
} from "@/components/tv";
|
||||||
import type { Track } from "@/components/video-player/controls/types";
|
import type { Track } from "@/components/video-player/controls/types";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||||
@@ -71,9 +69,8 @@ interface ItemContentTVProps {
|
|||||||
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
|
||||||
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
||||||
({ item, itemWithSources }) => {
|
({ item, itemWithSources }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [_user] = useAtom(userAtom);
|
||||||
const isOffline = useOfflineMode();
|
const isOffline = useOfflineMode();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -83,31 +80,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
|
|
||||||
const _itemColors = useImageColorsReturn({ item });
|
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<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
SelectedOptions | undefined
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
@@ -163,13 +135,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
const { showSubtitleModal } = useTVSubtitleModal();
|
const { showSubtitleModal } = useTVSubtitleModal();
|
||||||
|
|
||||||
// State for first actor card ref (used for focus guide)
|
// State for first actor card ref (used for focus guide)
|
||||||
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// State for last option button ref (used for upward focus guide from cast)
|
// State for last option button ref (used for upward focus guide from cast)
|
||||||
const [_lastOptionButtonRef, setLastOptionButtonRef] =
|
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
|
||||||
useState<View | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Get available audio tracks
|
// Get available audio tracks
|
||||||
const audioTracks = useMemo(() => {
|
const audioTracks = useMemo(() => {
|
||||||
@@ -413,18 +386,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
|
||||||
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
|
}, [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)
|
// Determine which option button is the last one (for focus guide targeting)
|
||||||
const lastOptionButton = useMemo(() => {
|
const lastOptionButton = useMemo(() => {
|
||||||
const hasSubtitleOption =
|
const hasSubtitleOption =
|
||||||
@@ -466,14 +427,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
}
|
}
|
||||||
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
|
}, [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;
|
if (!item || !selectedOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -503,7 +456,36 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
minHeight: SCREEN_HEIGHT * 0.45,
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Content */}
|
{/* 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 */}
|
||||||
<View style={{ flex: 1, justifyContent: "center" }}>
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
{/* Logo or Title */}
|
{/* Logo or Title */}
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
@@ -520,7 +502,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.display,
|
fontSize: TVTypography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
@@ -536,7 +518,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.title,
|
fontSize: TVTypography.title,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
@@ -545,7 +527,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "white",
|
color: "white",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
@@ -590,7 +572,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
@@ -623,7 +605,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -732,6 +714,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
)}
|
)}
|
||||||
</View>
|
</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) */}
|
{/* Progress bar (if partially watched) */}
|
||||||
{hasProgress && item.RunTimeTicks != null && (
|
{hasProgress && item.RunTimeTicks != null && (
|
||||||
<TVProgressBar
|
<TVProgressBar
|
||||||
@@ -743,112 +733,11 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
|
||||||
{/* Additional info section */}
|
{/* Additional info section */}
|
||||||
<View style={{ marginTop: 40 }}>
|
<View style={{ marginTop: 40 }}>
|
||||||
{/* Season Episodes - Episode only */}
|
{/* Cast & Crew (text version) */}
|
||||||
{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
|
<TVCastCrewText
|
||||||
director={director}
|
director={director}
|
||||||
cast={cast}
|
cast={cast}
|
||||||
@@ -862,6 +751,26 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
mediaStreams={selectedOptions.mediaSource.MediaStreams}
|
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>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Dimensions, View } from "react-native";
|
import { Dimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||||
|
|
||||||
export const ItemContentSkeletonTV: React.FC = () => {
|
export const ItemContentSkeletonTV: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
paddingTop: insets.top + 140,
|
paddingTop: 180,
|
||||||
paddingHorizontal: insets.left + 80,
|
paddingHorizontal: 160,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Content placeholders */}
|
{/* Left side - Poster placeholder */}
|
||||||
<View style={{ flex: 1 }}>
|
<View
|
||||||
{/* Logo placeholder */}
|
style={{
|
||||||
|
width: SCREEN_WIDTH * 0.22,
|
||||||
|
marginRight: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: 150,
|
aspectRatio: 2 / 3,
|
||||||
width: "80%",
|
borderRadius: 16,
|
||||||
|
backgroundColor: "#1a1a1a",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Right side - Content placeholders */}
|
||||||
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
||||||
|
{/* Logo/Title placeholder */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 80,
|
||||||
|
width: "60%",
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
@@ -142,22 +155,6 @@ export const ItemContentSkeletonTV: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
909
components/apple-tv-carousel/AppleTVCarousel.tsx
Normal file
909
components/apple-tv-carousel/AppleTVCarousel.tsx
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
51
components/apple-tv-carousel/MarkAsPlayedLargeButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
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)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
<RNText
|
<RNText
|
||||||
allowFontScaling={false}
|
allowFontScaling={true}
|
||||||
style={[{ color: "white" }, style]}
|
style={[{ color: "white" }, style]}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import heart from "@/assets/icons/heart.fill.png";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
@@ -28,7 +28,6 @@ type FavoriteTypes =
|
|||||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||||
|
|
||||||
export const Favorites = () => {
|
export const Favorites = () => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -149,7 +148,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -161,7 +160,7 @@ export const Favorites = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -598,14 +598,11 @@ const HomeMobile = () => {
|
|||||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||||
>
|
>
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
// Render Streamystats sections after Recently Added sections
|
// Render Streamystats sections after Continue Watching and Next Up
|
||||||
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
// When merged, they appear after index 0; otherwise after index 1
|
||||||
// For custom sections: place at the very end
|
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||||
const hasSuggestedMovies =
|
? 0
|
||||||
!settings?.streamyStatsMovieRecommendations &&
|
: 1;
|
||||||
!settings?.home?.sections;
|
|
||||||
const streamystatsIndex =
|
|
||||||
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
|
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
|
||||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
|
||||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
|
||||||
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
@@ -42,8 +41,8 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
// Generous gap between sections for Apple TV+ aesthetic
|
// Reduced gap since sections have internal padding for scale animations
|
||||||
const SECTION_GAP = 24;
|
const SECTION_GAP = 10;
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
@@ -62,7 +61,6 @@ type Section = InfiniteScrollingCollectionListSection;
|
|||||||
const BACKDROP_DEBOUNCE_MS = 300;
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const _router = useRouter();
|
const _router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -203,58 +201,6 @@ export const Home = () => {
|
|||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 60 * 1000,
|
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(
|
const userViews = useMemo(
|
||||||
@@ -580,7 +526,7 @@ export const Home = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -592,7 +538,7 @@ export const Home = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -633,7 +579,7 @@ export const Home = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -645,7 +591,7 @@ export const Home = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -661,106 +607,84 @@ export const Home = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine if hero should be shown (separate setting from backdrop)
|
|
||||||
const showHero =
|
|
||||||
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
|
{/* Dynamic backdrop with crossfade */}
|
||||||
{!showHero && settings.showHomeBackdrop && (
|
<View
|
||||||
<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]}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{/* Layer 0 */}
|
</View>
|
||||||
<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
|
<ScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
|
paddingTop: insets.top + TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + 60,
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingRight: insets.right + HORIZONTAL_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Hero Carousel - Apple TV+ style featured content */}
|
<View style={{ gap: SECTION_GAP }}>
|
||||||
{showHero && (
|
{sections.map((section, index) => {
|
||||||
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
|
// Render Streamystats sections after Continue Watching and Next Up
|
||||||
)}
|
// When merged, they appear after index 0; otherwise after index 1
|
||||||
|
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||||
<View
|
? 0
|
||||||
style={{
|
: 1;
|
||||||
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 =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
@@ -799,8 +723,7 @@ export const Home = () => {
|
|||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
const isHighPriority = section.priority === 1;
|
const isHighPriority = section.priority === 1;
|
||||||
// First section only gets preferred focus if hero is not shown
|
const isFirstSection = index === 0;
|
||||||
const isFirstSection = index === 0 && !showHero;
|
|
||||||
return (
|
return (
|
||||||
<View key={index} style={{ gap: SECTION_GAP }}>
|
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
|
|||||||
631
components/home/HomeWithCarousel.tsx
Normal file
631
components/home/HomeWithCarousel.tsx
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
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,6 +71,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import MoviePoster, {
|
|||||||
TV_POSTER_WIDTH,
|
TV_POSTER_WIDTH,
|
||||||
} from "@/components/posters/MoviePoster.tv";
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||||
import ContinueWatchingPoster, {
|
import ContinueWatchingPoster, {
|
||||||
@@ -28,7 +28,7 @@ import ContinueWatchingPoster, {
|
|||||||
} from "../ContinueWatchingPoster.tv";
|
} from "../ContinueWatchingPoster.tv";
|
||||||
import SeriesPoster from "../posters/SeriesPoster.tv";
|
import SeriesPoster from "../posters/SeriesPoster.tv";
|
||||||
|
|
||||||
const ITEM_GAP = 24;
|
const ITEM_GAP = 16;
|
||||||
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
// Extra padding to accommodate scale animation (1.05x) and glow shadow
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
@@ -48,27 +48,22 @@ interface Props extends ViewProps {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
|
||||||
|
|
||||||
// TV-specific ItemCardText with larger fonts
|
// TV-specific ItemCardText with larger fonts
|
||||||
const TVItemCardText: React.FC<{
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
item: BaseItemDto;
|
|
||||||
typography: Typography;
|
|
||||||
}> = ({ item, typography }) => {
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -82,13 +77,13 @@ const TVItemCardText: React.FC<{
|
|||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -108,8 +103,7 @@ const TVSeeAllCard: React.FC<{
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
typography: Typography;
|
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
|
||||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const width =
|
const width =
|
||||||
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
|
||||||
@@ -143,7 +137,7 @@ const TVSeeAllCard: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
@@ -171,7 +165,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
parentId,
|
parentId,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const effectivePageSize = Math.max(1, pageSize);
|
const effectivePageSize = Math.max(1, pageSize);
|
||||||
const hasCalledOnLoaded = useRef(false);
|
const hasCalledOnLoaded = useRef(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -218,7 +211,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchInterval: 60 * 1000,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -350,7 +343,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{renderPoster()}
|
{renderPoster()}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} typography={typography} />
|
<TVItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -361,7 +354,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
handleItemPress,
|
handleItemPress,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
handleItemBlur,
|
handleItemBlur,
|
||||||
typography,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -373,12 +365,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "700",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -388,7 +379,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#737373",
|
color: "#737373",
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -429,7 +420,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
color: "#262626",
|
color: "#262626",
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -486,7 +477,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onFocus={handleSeeAllFocus}
|
onFocus={handleSeeAllFocus}
|
||||||
onBlur={handleItemBlur}
|
onBlur={handleItemBlur}
|
||||||
typography={typography}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
|||||||
queryKey: queryKey,
|
queryKey: queryKey,
|
||||||
queryFn,
|
queryFn,
|
||||||
staleTime: 60 * 1000, // 1 minute
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled: enableLazyLoading ? isInView : true,
|
enabled: enableLazyLoading ? isInView : true,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,6 +215,7 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import MoviePoster, {
|
|||||||
} from "@/components/posters/MoviePoster.tv";
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -26,23 +26,18 @@ import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
|||||||
const ITEM_GAP = 16;
|
const ITEM_GAP = 16;
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{
|
|
||||||
item: BaseItemDto;
|
|
||||||
typography: Typography;
|
|
||||||
}> = ({ item, typography }) => {
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -65,7 +60,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
onItemFocus,
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@@ -114,8 +108,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
Boolean(settings?.streamyStatsServerUrl) &&
|
Boolean(settings?.streamyStatsServerUrl) &&
|
||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchInterval: 60 * 1000,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -148,11 +142,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} typography={typography} />
|
<TVItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress, onItemFocus, typography],
|
[handleItemPress, onItemFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isLoading && (!items || items.length === 0)) return null;
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
@@ -161,12 +155,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
<View style={{ overflow: "visible" }} {...props}>
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "700",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{watchlist.name}
|
{watchlist.name}
|
||||||
@@ -285,8 +278,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchInterval: 60 * 1000,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
enabled:
|
enabled:
|
||||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import MoviePoster, {
|
|||||||
} from "@/components/posters/MoviePoster.tv";
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -26,8 +26,6 @@ import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystat
|
|||||||
const ITEM_GAP = 16;
|
const ITEM_GAP = 16;
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
type Typography = ReturnType<typeof useScaledTVTypography>;
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
title: string;
|
title: string;
|
||||||
type: "Movie" | "Series";
|
type: "Movie" | "Series";
|
||||||
@@ -36,21 +34,18 @@ interface Props extends ViewProps {
|
|||||||
onItemFocus?: (item: BaseItemDto) => void;
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
item: BaseItemDto;
|
|
||||||
typography: Typography;
|
|
||||||
}> = ({ item, typography }) => {
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -69,7 +64,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
onItemFocus,
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@@ -139,8 +133,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchInterval: 60 * 1000,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,8 +166,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
enabled:
|
enabled:
|
||||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||||
staleTime: 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchInterval: 60 * 1000,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,11 +203,11 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} typography={typography} />
|
<TVItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress, onItemFocus, typography],
|
[handleItemPress, onItemFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!streamyStatsEnabled) return null;
|
if (!streamyStatsEnabled) return null;
|
||||||
@@ -224,12 +218,11 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
<View style={{ overflow: "visible" }} {...props}>
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "700",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -1,624 +0,0 @@
|
|||||||
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 { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
type DiscoverEndpoint,
|
type DiscoverEndpoint,
|
||||||
@@ -33,7 +33,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
item,
|
item,
|
||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
@@ -131,7 +130,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -143,7 +142,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
{year && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -165,7 +164,6 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
slide,
|
slide,
|
||||||
isFirstSlide = false,
|
isFirstSlide = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||||
|
|
||||||
@@ -234,7 +232,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Loader } from "@/components/Loader";
|
|||||||
import { JellyserrRatings } from "@/components/Ratings";
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
import { TVButton } from "@/components/tv";
|
import { TVButton } from "@/components/tv";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
|
||||||
@@ -68,7 +68,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
@@ -129,7 +128,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -159,7 +158,6 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TVJellyseerrPage: React.FC = () => {
|
export const TVJellyseerrPage: React.FC = () => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -554,7 +552,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.display,
|
fontSize: TVTypography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -568,7 +566,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
{/* Year */}
|
{/* Year */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
@@ -603,7 +601,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
@@ -638,7 +636,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -665,7 +663,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -700,7 +698,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -734,7 +732,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -759,7 +757,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -778,7 +776,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -796,7 +794,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -815,7 +813,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
<View style={{ marginTop: 24 }}>
|
<View style={{ marginTop: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
@@ -51,7 +51,6 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onRequested,
|
onRequested,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
@@ -390,7 +389,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -400,7 +399,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
@@ -474,7 +473,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface TVRequestOptionRowProps {
|
interface TVRequestOptionRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,7 +20,6 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
hasTVPreferredFocus = false,
|
hasTVPreferredFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({
|
useTVFocusAnimation({
|
||||||
scaleAmount: 1.02,
|
scaleAmount: 1.02,
|
||||||
@@ -57,7 +56,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -66,7 +65,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { Animated, Pressable, ScrollView, View } from "react-native";
|
import { Animated, Pressable, ScrollView, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface ToggleItem {
|
interface ToggleItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,7 +21,6 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({
|
useTVFocusAnimation({
|
||||||
scaleAmount: 1.08,
|
scaleAmount: 1.08,
|
||||||
@@ -58,7 +57,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: item.selected || focused ? "600" : "400",
|
fontWeight: item.selected || focused ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
@@ -83,14 +82,13 @@ export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { Animated, Easing, FlatList, Pressable, View } from "react-native";
|
|||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
@@ -56,7 +55,6 @@ const TVLibraryRow: React.FC<{
|
|||||||
}> = ({ library, isFirst, onPress }) => {
|
}> = ({ library, isFirst, onPress }) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
const opacity = useRef(new Animated.Value(0.7)).current;
|
const opacity = useRef(new Animated.Value(0.7)).current;
|
||||||
@@ -192,7 +190,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: 32,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
textShadowColor: "rgba(0,0,0,0.8)",
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
@@ -205,7 +203,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
{library.itemCount !== undefined && (
|
{library.itemCount !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: 18,
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
textShadowColor: "rgba(0,0,0,0.8)",
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
@@ -239,7 +237,6 @@ export const TVLibraries: React.FC = () => {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
const { data: userViews, isLoading: viewsLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
@@ -363,7 +360,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
<Text style={{ fontSize: 20, color: "#737373" }}>
|
||||||
{t("library.no_libraries_found")}
|
{t("library.no_libraries_found")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -150,7 +148,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: 22,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -162,7 +160,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
{itemsCount !== undefined && (
|
{itemsCount !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: 14,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { fontSize, size } from "react-native-responsive-sizes";
|
||||||
|
|
||||||
interface TVInputProps extends TextInputProps {
|
interface TVInputProps extends TextInputProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -58,8 +59,8 @@ export const TVInput: React.FC<TVInputProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
borderRadius: 10,
|
borderRadius: size(20),
|
||||||
borderWidth: 3,
|
borderWidth: size(6),
|
||||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -69,8 +70,8 @@ export const TVInput: React.FC<TVInputProps> = ({
|
|||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: 68,
|
height: size(200),
|
||||||
fontSize: 24,
|
fontSize: fontSize(12),
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { fontSize, size } from "react-native-responsive-sizes";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -66,7 +67,7 @@ const TVBackButton: React.FC<{
|
|||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
animateFocus(false);
|
animateFocus(false);
|
||||||
}}
|
}}
|
||||||
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
style={{ alignSelf: "flex-start", marginBottom: size(40) }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
focusable={!disabled}
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
@@ -75,26 +76,25 @@ const TVBackButton: React.FC<{
|
|||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: 8,
|
paddingHorizontal: size(12),
|
||||||
paddingHorizontal: 12,
|
borderRadius: size(8),
|
||||||
borderRadius: 8,
|
|
||||||
backgroundColor: isFocused
|
backgroundColor: isFocused
|
||||||
? "rgba(168, 85, 247, 0.2)"
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
borderWidth: 2,
|
borderWidth: size(2),
|
||||||
borderColor: isFocused ? Colors.primary : "transparent",
|
borderColor: isFocused ? Colors.primary : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-back'
|
name='chevron-back'
|
||||||
size={28}
|
size={size(28)}
|
||||||
color={isFocused ? "#FFFFFF" : Colors.primary}
|
color={isFocused ? "#FFFFFF" : Colors.primary}
|
||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: isFocused ? "#FFFFFF" : Colors.primary,
|
color: isFocused ? "#FFFFFF" : Colors.primary,
|
||||||
fontSize: 20,
|
fontSize: fontSize(14),
|
||||||
marginLeft: 4,
|
marginLeft: size(4),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -463,7 +463,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: 60,
|
paddingVertical: size(20),
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
@@ -471,7 +471,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
paddingHorizontal: 60,
|
paddingHorizontal: size(40),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
@@ -484,10 +484,10 @@ export const TVLogin: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 48,
|
fontSize: fontSize(12),
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: size(8),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
@@ -501,16 +501,18 @@ export const TVLogin: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: fontSize(14),
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: 40,
|
marginBottom: size(40),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Username Input - extra padding for focus scale */}
|
{/* Username Input - extra padding for focus scale */}
|
||||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
<View
|
||||||
|
style={{ marginBottom: size(24), paddingHorizontal: size(8) }}
|
||||||
|
>
|
||||||
<TVInput
|
<TVInput
|
||||||
placeholder={t("login.username_placeholder")}
|
placeholder={t("login.username_placeholder")}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
@@ -527,7 +529,9 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Password Input */}
|
{/* Password Input */}
|
||||||
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
<View
|
||||||
|
style={{ marginBottom: size(32), paddingHorizontal: size(8) }}
|
||||||
|
>
|
||||||
<TVInput
|
<TVInput
|
||||||
placeholder={t("login.password_placeholder")}
|
placeholder={t("login.password_placeholder")}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
@@ -543,7 +547,9 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Save Account Toggle */}
|
{/* Save Account Toggle */}
|
||||||
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
<View
|
||||||
|
style={{ marginBottom: size(40), paddingHorizontal: size(8) }}
|
||||||
|
>
|
||||||
<TVSaveAccountToggle
|
<TVSaveAccountToggle
|
||||||
value={saveAccount}
|
value={saveAccount}
|
||||||
onValueChange={setSaveAccount}
|
onValueChange={setSaveAccount}
|
||||||
@@ -553,7 +559,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Login Button */}
|
{/* Login Button */}
|
||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: size(16) }}>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -579,9 +585,11 @@ export const TVLogin: React.FC = () => {
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
justifyContent: "center",
|
// justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingVertical: 60,
|
justifyContent: "flex-start",
|
||||||
|
paddingTop: size(40),
|
||||||
|
paddingBottom: size(40),
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
@@ -589,14 +597,14 @@ export const TVLogin: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
paddingHorizontal: 60,
|
paddingHorizontal: size(60),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
<View style={{ alignItems: "center", marginBottom: size(5) }}>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/images/icon-tvos.png")}
|
source={require("@/assets/images/icon-tvos.png")}
|
||||||
style={{ width: 150, height: 150 }}
|
style={{ width: size(400), height: size(400) }}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -604,28 +612,30 @@ export const TVLogin: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 48,
|
fontSize: fontSize(24),
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 8,
|
marginBottom: size(8),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Streamyfin
|
Streamyfin
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 20,
|
fontSize: fontSize(10),
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: 40,
|
marginBottom: size(40),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("server.enter_url_to_jellyfin_server")}
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Server URL Input - extra padding for focus scale */}
|
{/* Server URL Input - extra padding for focus scale */}
|
||||||
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
<View
|
||||||
|
style={{ marginBottom: size(50), paddingHorizontal: size(300) }}
|
||||||
|
>
|
||||||
<TVInput
|
<TVInput
|
||||||
placeholder={t("server.server_url_placeholder")}
|
placeholder={t("server.server_url_placeholder")}
|
||||||
value={serverURL}
|
value={serverURL}
|
||||||
@@ -640,7 +650,9 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Connect Button */}
|
{/* Connect Button */}
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View
|
||||||
|
style={{ marginBottom: size(12), paddingHorizontal: size(270) }}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => handleConnect(serverURL)}
|
onPress={() => handleConnect(serverURL)}
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
@@ -651,7 +663,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Previous Servers */}
|
{/* Previous Servers */}
|
||||||
<View style={{ paddingHorizontal: 8 }}>
|
<View style={{ paddingHorizontal: size(300) }}>
|
||||||
<TVPreviousServersList
|
<TVPreviousServersList
|
||||||
onServerSelect={(s) => handleConnect(s.address)}
|
onServerSelect={(s) => handleConnect(s.address)}
|
||||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useMMKVString } from "react-native-mmkv";
|
import { useMMKVString } from "react-native-mmkv";
|
||||||
|
import { fontSize, height, size, width } from "react-native-responsive-sizes";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import {
|
import {
|
||||||
@@ -61,8 +62,8 @@ const TVServerActionCard: React.FC<{
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
width: 180,
|
width: width(25),
|
||||||
height: 90,
|
height: height(15),
|
||||||
backgroundColor: focused
|
backgroundColor: focused
|
||||||
? isDestructive
|
? isDestructive
|
||||||
? "#ef4444"
|
? "#ef4444"
|
||||||
@@ -70,16 +71,16 @@ const TVServerActionCard: React.FC<{
|
|||||||
: isDestructive
|
: isDestructive
|
||||||
? "rgba(239, 68, 68, 0.2)"
|
? "rgba(239, 68, 68, 0.2)"
|
||||||
: "rgba(255,255,255,0.08)",
|
: "rgba(255,255,255,0.08)",
|
||||||
borderRadius: 14,
|
borderRadius: size(20),
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: size(12),
|
||||||
gap: 8,
|
gap: size(8),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={icon}
|
name={icon}
|
||||||
size={28}
|
size={size(56)}
|
||||||
color={
|
color={
|
||||||
focused
|
focused
|
||||||
? isDestructive
|
? isDestructive
|
||||||
@@ -92,7 +93,7 @@ const TVServerActionCard: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: fontSize(12),
|
||||||
color: focused
|
color: focused
|
||||||
? isDestructive
|
? isDestructive
|
||||||
? "#fff"
|
? "#fff"
|
||||||
@@ -142,26 +143,26 @@ const TVServerActionSheet: React.FC<{
|
|||||||
intensity={80}
|
intensity={80}
|
||||||
tint='dark'
|
tint='dark'
|
||||||
style={{
|
style={{
|
||||||
borderTopLeftRadius: 24,
|
borderTopLeftRadius: size(24),
|
||||||
borderTopRightRadius: 24,
|
borderTopRightRadius: size(24),
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingTop: 24,
|
paddingTop: size(24),
|
||||||
paddingBottom: 50,
|
paddingBottom: size(50),
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: fontSize(12),
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 8,
|
marginBottom: size(12),
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: size(48),
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
}}
|
}}
|
||||||
@@ -175,9 +176,9 @@ const TVServerActionSheet: React.FC<{
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: 48,
|
paddingHorizontal: size(48),
|
||||||
paddingVertical: 10,
|
paddingVertical: size(10),
|
||||||
gap: 12,
|
gap: size(12),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVServerActionCard
|
<TVServerActionCard
|
||||||
@@ -398,19 +399,19 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
if (!previousServers.length) return null;
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 32 }}>
|
<View style={{ marginTop: size(100) }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 24,
|
fontSize: fontSize(12),
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: size(20),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("server.previous_servers")}
|
{t("server.previous_servers")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ gap: 12 }}>
|
<View style={{ gap: size(12) }}>
|
||||||
{previousServers.map((server) => (
|
{previousServers.map((server) => (
|
||||||
<TVServerCard
|
<TVServerCard
|
||||||
key={server.address}
|
key={server.address}
|
||||||
@@ -437,39 +438,39 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: 80,
|
padding: size(80),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
borderRadius: 24,
|
borderRadius: size(24),
|
||||||
padding: 40,
|
padding: size(40),
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 700,
|
maxWidth: width(70),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 32,
|
fontSize: fontSize(32),
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: size(8),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("server.select_account")}
|
{t("server.select_account")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: fontSize(18),
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: 32,
|
marginBottom: size(32),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedServer?.name || selectedServer?.address}
|
{selectedServer?.name || selectedServer?.address}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ gap: 12, marginBottom: 24 }}>
|
<View style={{ gap: size(12), marginBottom: size(24) }}>
|
||||||
{selectedServer?.accounts.map((account, index) => (
|
{selectedServer?.accounts.map((account, index) => (
|
||||||
<TVAccountCard
|
<TVAccountCard
|
||||||
key={account.userId}
|
key={account.userId}
|
||||||
@@ -484,7 +485,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ gap: 12 }}>
|
<View style={{ gap: size(12) }}>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowAccountsModal(false);
|
setShowAccountsModal(false);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { fontSize, size } from "react-native-responsive-sizes";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
@@ -86,11 +87,11 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||||
borderWidth: 2,
|
borderWidth: size(6),
|
||||||
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
borderRadius: 16,
|
borderRadius: size(50),
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: size(50),
|
||||||
paddingVertical: 20,
|
paddingVertical: size(40),
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -99,7 +100,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: fontSize(12),
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -110,9 +111,9 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: fontSize(10),
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 4,
|
marginTop: size(12),
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -121,27 +122,27 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ marginLeft: 16 }}>
|
<View style={{ marginLeft: size(16) }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size='small' color={Colors.primary} />
|
<ActivityIndicator size='small' color={Colors.primary} />
|
||||||
) : securityIcon ? (
|
) : securityIcon ? (
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={securityIcon}
|
name={securityIcon}
|
||||||
size={20}
|
size={size(60)}
|
||||||
color={Colors.primary}
|
color={Colors.primary}
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: size(16) }}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-forward'
|
name='chevron-forward'
|
||||||
size={24}
|
size={size(48)}
|
||||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-forward'
|
name='chevron-forward'
|
||||||
size={24}
|
size={size(48)}
|
||||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ import { useAtom } from "jotai";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
import {
|
|
||||||
GlassPosterView,
|
|
||||||
isGlassEffectAvailable,
|
|
||||||
} from "@/modules/glass-poster";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
export const TV_POSTER_WIDTH = 260;
|
export const TV_POSTER_WIDTH = 210;
|
||||||
|
|
||||||
type MoviePosterProps = {
|
type MoviePosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -28,37 +24,17 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
return getPrimaryImageUrl({
|
return getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
width: 520, // 2x for quality on large screens
|
width: 420, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
const progress = item.UserData?.PlayedPercentage || 0;
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
const isWatched = item.UserData?.Played === true;
|
|
||||||
|
|
||||||
const blurhash = useMemo(() => {
|
const blurhash = useMemo(() => {
|
||||||
const key = item.ImageTags?.Primary as string;
|
const key = item.ImageTags?.Primary as string;
|
||||||
return item.ImageBlurHashes?.Primary?.[key];
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
}, [item]);
|
}, [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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import { Image } from "expo-image";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import {
|
|
||||||
GlassPosterView,
|
|
||||||
isGlassEffectAvailable,
|
|
||||||
} from "@/modules/glass-poster";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
export const TV_POSTER_WIDTH = 260;
|
export const TV_POSTER_WIDTH = 210;
|
||||||
|
|
||||||
type SeriesPosterProps = {
|
type SeriesPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -22,12 +18,12 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
|||||||
|
|
||||||
const url = useMemo(() => {
|
const url = useMemo(() => {
|
||||||
if (item.Type === "Episode") {
|
if (item.Type === "Episode") {
|
||||||
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||||
}
|
}
|
||||||
return getPrimaryImageUrl({
|
return getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
width: 520, // 2x for quality on large screens
|
width: 420, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
@@ -36,25 +32,6 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
|
|||||||
return item.ImageBlurHashes?.Primary?.[key];
|
return item.ImageBlurHashes?.Primary?.[key];
|
||||||
}, [item]);
|
}, [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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||||
import type {
|
import type {
|
||||||
@@ -27,7 +27,6 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
isFirstItem = false,
|
isFirstItem = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
@@ -114,7 +113,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -126,7 +125,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
{year && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -148,7 +147,6 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
@@ -204,7 +202,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -232,14 +230,13 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
|||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -284,14 +281,13 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
|||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -336,14 +332,13 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
|||||||
isFirstSection: _isFirstSection = false,
|
isFirstSection: _isFirstSection = false,
|
||||||
onItemPress,
|
onItemPress,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import { Animated, Pressable } from "react-native";
|
import { Animated, Pressable } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
|
|
||||||
export interface TVSearchBadgeProps {
|
export interface TVSearchBadgeProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -15,7 +14,6 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
hasTVPreferredFocus = false,
|
hasTVPreferredFocus = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||||
|
|
||||||
@@ -43,7 +41,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: 16,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: focused ? "600" : "400",
|
fontWeight: focused ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Input } from "@/components/common/Input";
|
import { Input } from "@/components/common/Input";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||||
@@ -27,7 +26,6 @@ const SCALE_PADDING = 20;
|
|||||||
|
|
||||||
// Loading skeleton for TV
|
// Loading skeleton for TV
|
||||||
const TVLoadingSkeleton: React.FC = () => {
|
const TVLoadingSkeleton: React.FC = () => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const itemWidth = 210;
|
const itemWidth = 210;
|
||||||
return (
|
return (
|
||||||
<View style={{ overflow: "visible" }}>
|
<View style={{ overflow: "visible" }}>
|
||||||
@@ -73,7 +71,7 @@ const TVLoadingSkeleton: React.FC = () => {
|
|||||||
color: "#262626",
|
color: "#262626",
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontSize: typography.callout,
|
fontSize: 16,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -151,7 +149,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
onJellyseerrPersonPress,
|
onJellyseerrPersonPress,
|
||||||
discoverSliders,
|
discoverSliders,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -225,7 +222,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING + 200 }}>
|
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
value={search}
|
value={search}
|
||||||
@@ -310,7 +307,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: 24,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -318,12 +315,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
>
|
>
|
||||||
{t("search.no_results_found_for")}
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||||
style={{
|
|
||||||
fontSize: typography.body,
|
|
||||||
color: "rgba(255,255,255,0.6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
"{debouncedSearch}"
|
"{debouncedSearch}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,31 +11,22 @@ import MoviePoster, {
|
|||||||
} from "@/components/posters/MoviePoster.tv";
|
} from "@/components/posters/MoviePoster.tv";
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
|
|
||||||
const ITEM_GAP = 16;
|
const ITEM_GAP = 16;
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
// TV-specific ItemCardText with larger fonts
|
// TV-specific ItemCardText with larger fonts
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
{" - "}
|
{" - "}
|
||||||
@@ -45,92 +36,53 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
) : item.Type === "MusicArtist" ? (
|
) : item.Type === "MusicArtist" ? (
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
style={{
|
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#FFFFFF",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
) : item.Type === "MusicAlbum" ? (
|
) : item.Type === "MusicAlbum" ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={2}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.AlbumArtist || item.Artists?.join(", ")}
|
{item.AlbumArtist || item.Artists?.join(", ")}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : item.Type === "Audio" ? (
|
) : item.Type === "Audio" ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={2}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.Artists?.join(", ") || item.AlbumArtist}
|
{item.Artists?.join(", ") || item.AlbumArtist}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : item.Type === "Playlist" ? (
|
) : item.Type === "Playlist" ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={2}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
style={{
|
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ChildCount} tracks
|
{item.ChildCount} tracks
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : item.Type === "Person" ? (
|
) : item.Type === "Person" ? (
|
||||||
<Text
|
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={2}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
||||||
numberOfLines={1}
|
|
||||||
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
|
||||||
>
|
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
||||||
style={{
|
|
||||||
fontSize: typography.callout,
|
|
||||||
color: "#9CA3AF",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -159,7 +111,6 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
imageUrlGetter,
|
imageUrlGetter,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
|
||||||
const [focusedCount, setFocusedCount] = useState(0);
|
const [focusedCount, setFocusedCount] = useState(0);
|
||||||
const prevFocusedCount = useRef(0);
|
const prevFocusedCount = useRef(0);
|
||||||
@@ -360,12 +311,11 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: 22,
|
||||||
fontWeight: "700",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 16,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from "react";
|
|||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
|
|||||||
hasTVPreferredFocus = false,
|
hasTVPreferredFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: 16,
|
||||||
color: getTextColor(),
|
color: getTextColor(),
|
||||||
fontWeight: isSelected || focused ? "600" : "400",
|
fontWeight: isSelected || focused ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ProgressBar } from "@/components/common/ProgressBar";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
||||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
onBlur,
|
onBlur,
|
||||||
refSetter,
|
refSetter,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const thumbnailUrl = useMemo(() => {
|
const thumbnailUrl = useMemo(() => {
|
||||||
@@ -68,7 +67,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}>
|
<View style={{ width: TV_EPISODE_WIDTH }}>
|
||||||
<TVFocusablePoster
|
<TVFocusablePoster
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
@@ -113,8 +112,8 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
{episodeLabel && (
|
{episodeLabel && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#FFFFFF",
|
color: "#9CA3AF",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -123,10 +122,14 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{duration && (
|
{duration && (
|
||||||
<>
|
<>
|
||||||
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
|
<Text
|
||||||
|
style={{ color: "#6B7280", fontSize: TVTypography.callout }}
|
||||||
|
>
|
||||||
•
|
•
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
|
||||||
|
>
|
||||||
{duration}
|
{duration}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -135,7 +138,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dimensions, View } from "react-native";
|
|||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ interface TVSeriesHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
const logoUrl = useMemo(() => {
|
const logoUrl = useMemo(() => {
|
||||||
@@ -59,7 +58,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.display,
|
fontSize: TVTypography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -81,7 +80,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{yearString && (
|
{yearString && (
|
||||||
<Text style={{ color: "white", fontSize: typography.body }}>
|
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
||||||
{yearString}
|
{yearString}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -124,7 +123,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
|||||||
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
|
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
|
||||||
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
|
||||||
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
|
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
@@ -142,7 +142,6 @@ const TVSeasonButton: React.FC<{
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}> = ({ seasonName, onPress, disabled = false }) => {
|
}> = ({ seasonName, onPress, disabled = false }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
@@ -191,7 +190,7 @@ const TVSeasonButton: React.FC<{
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: focused ? "#000" : "#FFFFFF",
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
@@ -214,7 +213,6 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
allEpisodes = [],
|
allEpisodes = [],
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -295,7 +293,6 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
return response.data.Items || [];
|
return response.data.Items || [];
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
refetchInterval: !isOffline ? 60 * 1000 : undefined,
|
|
||||||
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,8 +345,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 0,
|
||||||
refetchInterval: !isOffline ? 60 * 1000 : undefined,
|
|
||||||
enabled: isOffline
|
enabled: isOffline
|
||||||
? !!item.Id && selectedSeasonNumber !== null
|
? !!item.Id && selectedSeasonNumber !== null
|
||||||
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||||
@@ -569,7 +565,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -593,10 +589,10 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
<View style={{ marginTop: 40, overflow: "visible" }}>
|
<View style={{ marginTop: 40, overflow: "visible" }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 24,
|
marginBottom: 16,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -648,7 +644,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#737373",
|
color: "#737373",
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export const AppearanceSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||||
|
<Switch
|
||||||
|
value={settings.showLargeHomeCarousel}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ showLargeHomeCarousel: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -158,6 +158,14 @@ export const OtherSettings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
|
||||||
|
<Switch
|
||||||
|
value={settings.showLargeHomeCarousel}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSettings({ showLargeHomeCarousel: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
<ListItem
|
<ListItem
|
||||||
onPress={() => router.push("/settings/hide-libraries/page")}
|
onPress={() => router.push("/settings/hide-libraries/page")}
|
||||||
title={t("home.settings.other.hide_libraries")}
|
title={t("home.settings.other.hide_libraries")}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Image } from "expo-image";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVActorCardProps {
|
export interface TVActorCardProps {
|
||||||
@@ -19,7 +19,6 @@ export interface TVActorCardProps {
|
|||||||
|
|
||||||
export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
||||||
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
|
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
|||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -99,7 +98,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
|||||||
{person.Role && (
|
{person.Role && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused
|
color: focused
|
||||||
? "rgba(255,255,255,0.8)"
|
? "rgba(255,255,255,0.8)"
|
||||||
: "rgba(255,255,255,0.5)",
|
: "rgba(255,255,255,0.5)",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable } from "react-native";
|
import { Animated, Pressable } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVCancelButtonProps {
|
export interface TVCancelButtonProps {
|
||||||
@@ -16,7 +16,6 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
|||||||
label = "Cancel",
|
label = "Cancel",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
color: focused ? "#000" : "rgba(255,255,255,0.8)",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVCastCrewTextProps {
|
export interface TVCastCrewTextProps {
|
||||||
director?: BaseItemPerson | null;
|
director?: BaseItemPerson | null;
|
||||||
@@ -14,7 +14,6 @@ export interface TVCastCrewTextProps {
|
|||||||
|
|
||||||
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
||||||
({ director, cast, hideCast = false }) => {
|
({ director, cast, hideCast = false }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!director && (!cast || cast.length === 0)) {
|
if (!director && (!cast || cast.length === 0)) {
|
||||||
@@ -25,7 +24,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -38,7 +37,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@@ -47,7 +46,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
>
|
>
|
||||||
{t("item_card.director")}
|
{t("item_card.director")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{director.Name}
|
{director.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -56,7 +55,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@@ -65,7 +64,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
>
|
>
|
||||||
{t("item_card.cast")}
|
{t("item_card.cast")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{cast.map((c) => c.Name).join(", ")}
|
{cast.map((c) => c.Name).join(", ")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, TVFocusGuideView, View } from "react-native";
|
import { ScrollView, TVFocusGuideView, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { TVActorCard } from "./TVActorCard";
|
import { TVActorCard } from "./TVActorCard";
|
||||||
|
|
||||||
export interface TVCastSectionProps {
|
export interface TVCastSectionProps {
|
||||||
@@ -24,7 +24,6 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
|
|||||||
firstActorRefSetter,
|
firstActorRefSetter,
|
||||||
upwardFocusDestination,
|
upwardFocusDestination,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (cast.length === 0) {
|
if (cast.length === 0) {
|
||||||
@@ -35,7 +34,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 40 }}>
|
<View style={{ marginBottom: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Animated as RNAnimated,
|
Animated as RNAnimated,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
|
||||||
type View,
|
type View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVControlButtonProps {
|
export interface TVControlButtonProps {
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
text?: string;
|
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
onLongPress?: () => void;
|
onLongPress?: () => void;
|
||||||
onPressOut?: () => void;
|
onPressOut?: () => void;
|
||||||
@@ -25,7 +23,6 @@ export interface TVControlButtonProps {
|
|||||||
|
|
||||||
export const TVControlButton: FC<TVControlButtonProps> = ({
|
export const TVControlButton: FC<TVControlButtonProps> = ({
|
||||||
icon,
|
icon,
|
||||||
text,
|
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onPressOut,
|
onPressOut,
|
||||||
@@ -66,11 +63,7 @@ export const TVControlButton: FC<TVControlButtonProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{text ? (
|
<Ionicons name={icon} size={size} color='#fff' />
|
||||||
<Text style={[styles.text, { fontSize: size * 0.4 }]}>{text}</Text>
|
|
||||||
) : (
|
|
||||||
<Ionicons name={icon!} size={size} color='#fff' />
|
|
||||||
)}
|
|
||||||
</RNAnimated.View>
|
</RNAnimated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -85,9 +78,4 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
text: {
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: "600",
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
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 }],
|
transform: [{ scale }],
|
||||||
shadowColor,
|
shadowColor,
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.3 : 0,
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
shadowRadius: focused ? 12 : 0,
|
shadowRadius: focused ? 20 : 0,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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 React from "react";
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVLanguageCardProps {
|
export interface TVLanguageCardProps {
|
||||||
@@ -15,8 +15,6 @@ export interface TVLanguageCardProps {
|
|||||||
|
|
||||||
export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
||||||
({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
|
({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const styles = createStyles(typography);
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
@@ -74,27 +72,26 @@ export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
const styles = StyleSheet.create({
|
||||||
StyleSheet.create({
|
languageCard: {
|
||||||
languageCard: {
|
width: 120,
|
||||||
width: 120,
|
height: 60,
|
||||||
height: 60,
|
borderRadius: 12,
|
||||||
borderRadius: 12,
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
paddingHorizontal: 12,
|
||||||
paddingHorizontal: 12,
|
},
|
||||||
},
|
languageCardText: {
|
||||||
languageCardText: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
fontWeight: "500",
|
||||||
fontWeight: "500",
|
},
|
||||||
},
|
languageCardCode: {
|
||||||
languageCardCode: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
marginTop: 2,
|
||||||
marginTop: 2,
|
},
|
||||||
},
|
checkmark: {
|
||||||
checkmark: {
|
position: "absolute",
|
||||||
position: "absolute",
|
top: 8,
|
||||||
top: 8,
|
right: 8,
|
||||||
right: 8,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Badge } from "@/components/Badge";
|
import { Badge } from "@/components/Badge";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVMetadataBadgesProps {
|
export interface TVMetadataBadgesProps {
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
@@ -14,8 +14,6 @@ export interface TVMetadataBadgesProps {
|
|||||||
|
|
||||||
export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
||||||
({ year, duration, officialRating, communityRating }) => {
|
({ year, duration, officialRating, communityRating }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
@@ -27,12 +25,12 @@ export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year != null && (
|
{year != null && (
|
||||||
<Text style={{ color: "white", fontSize: typography.body }}>
|
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
||||||
{year}
|
{year}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{duration && (
|
{duration && (
|
||||||
<Text style={{ color: "white", fontSize: typography.body }}>
|
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
||||||
{duration}
|
{duration}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Api } from "@jellyfin/sdk";
|
import type { Api } from "@jellyfin/sdk";
|
||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { type FC, useEffect, useMemo, useRef } from "react";
|
import { type FC, useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Image, StyleSheet, View } from "react-native";
|
import { Image, StyleSheet, View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -13,7 +13,7 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
export interface TVNextEpisodeCountdownProps {
|
export interface TVNextEpisodeCountdownProps {
|
||||||
@@ -31,7 +31,6 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
isPlaying,
|
isPlaying,
|
||||||
onFinish,
|
onFinish,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const progress = useSharedValue(0);
|
const progress = useSharedValue(0);
|
||||||
const onFinishRef = useRef(onFinish);
|
const onFinishRef = useRef(onFinish);
|
||||||
@@ -70,8 +69,6 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
width: `${progress.value * 100}%`,
|
width: `${progress.value * 100}%`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const styles = useMemo(() => createStyles(typography), [typography]);
|
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,58 +105,57 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
const styles = StyleSheet.create({
|
||||||
StyleSheet.create({
|
container: {
|
||||||
container: {
|
position: "absolute",
|
||||||
position: "absolute",
|
bottom: 180,
|
||||||
bottom: 180,
|
right: 80,
|
||||||
right: 80,
|
zIndex: 100,
|
||||||
zIndex: 100,
|
},
|
||||||
},
|
blur: {
|
||||||
blur: {
|
borderRadius: 16,
|
||||||
borderRadius: 16,
|
overflow: "hidden",
|
||||||
overflow: "hidden",
|
},
|
||||||
},
|
innerContainer: {
|
||||||
innerContainer: {
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
alignItems: "stretch",
|
||||||
alignItems: "stretch",
|
},
|
||||||
},
|
thumbnail: {
|
||||||
thumbnail: {
|
width: 180,
|
||||||
width: 180,
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
},
|
||||||
},
|
content: {
|
||||||
content: {
|
padding: 16,
|
||||||
padding: 16,
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
width: 280,
|
||||||
width: 280,
|
},
|
||||||
},
|
label: {
|
||||||
label: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
color: "rgba(255,255,255,0.5)",
|
||||||
color: "rgba(255,255,255,0.5)",
|
textTransform: "uppercase",
|
||||||
textTransform: "uppercase",
|
letterSpacing: 1,
|
||||||
letterSpacing: 1,
|
marginBottom: 4,
|
||||||
marginBottom: 4,
|
},
|
||||||
},
|
seriesName: {
|
||||||
seriesName: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
color: "rgba(255,255,255,0.7)",
|
||||||
color: "rgba(255,255,255,0.7)",
|
marginBottom: 2,
|
||||||
marginBottom: 2,
|
},
|
||||||
},
|
episodeInfo: {
|
||||||
episodeInfo: {
|
fontSize: TVTypography.body,
|
||||||
fontSize: typography.body,
|
color: "#fff",
|
||||||
color: "#fff",
|
fontWeight: "600",
|
||||||
fontWeight: "600",
|
marginBottom: 12,
|
||||||
marginBottom: 12,
|
},
|
||||||
},
|
progressContainer: {
|
||||||
progressContainer: {
|
height: 4,
|
||||||
height: 4,
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
borderRadius: 2,
|
||||||
borderRadius: 2,
|
overflow: "hidden",
|
||||||
overflow: "hidden",
|
},
|
||||||
},
|
progressBar: {
|
||||||
progressBar: {
|
height: "100%",
|
||||||
height: "100%",
|
backgroundColor: "#fff",
|
||||||
backgroundColor: "#fff",
|
borderRadius: 2,
|
||||||
borderRadius: 2,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BlurView } from "expo-blur";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVOptionButtonProps {
|
export interface TVOptionButtonProps {
|
||||||
@@ -14,7 +14,6 @@ export interface TVOptionButtonProps {
|
|||||||
|
|
||||||
export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
||||||
({ label, value, onPress, hasTVPreferredFocus }, ref) => {
|
({ label, value, onPress, hasTVPreferredFocus }, ref) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#444",
|
color: "#444",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -59,7 +58,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#000",
|
color: "#000",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
@@ -89,7 +88,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#bbb",
|
color: "#bbb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -97,7 +96,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVOptionCardProps {
|
export interface TVOptionCardProps {
|
||||||
@@ -28,7 +28,6 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: focused || selected ? "600" : "400",
|
fontWeight: focused || selected ? "600" : "400",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -72,7 +71,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
{sublabel && (
|
{sublabel && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { TVCancelButton } from "./TVCancelButton";
|
import { TVCancelButton } from "./TVCancelButton";
|
||||||
import { TVOptionCard } from "./TVOptionCard";
|
import { TVOptionCard } from "./TVOptionCard";
|
||||||
|
|
||||||
@@ -41,7 +41,6 @@ export const TVOptionSelector = <T,>({
|
|||||||
cardWidth = 160,
|
cardWidth = 160,
|
||||||
cardHeight = 75,
|
cardHeight = 75,
|
||||||
}: TVOptionSelectorProps<T>) => {
|
}: TVOptionSelectorProps<T>) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
const firstCardRef = useRef<View>(null);
|
const firstCardRef = useRef<View>(null);
|
||||||
|
|
||||||
@@ -92,8 +91,6 @@ export const TVOptionSelector = <T,>({
|
|||||||
}
|
}
|
||||||
}, [isReady]);
|
}, [isReady]);
|
||||||
|
|
||||||
const styles = useMemo(() => createStyles(typography), [typography]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,51 +151,50 @@ export const TVOptionSelector = <T,>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
const styles = StyleSheet.create({
|
||||||
StyleSheet.create({
|
overlay: {
|
||||||
overlay: {
|
position: "absolute",
|
||||||
position: "absolute",
|
top: 0,
|
||||||
top: 0,
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
justifyContent: "flex-end",
|
||||||
justifyContent: "flex-end",
|
zIndex: 1000,
|
||||||
zIndex: 1000,
|
},
|
||||||
},
|
sheetContainer: {
|
||||||
sheetContainer: {
|
width: "100%",
|
||||||
width: "100%",
|
},
|
||||||
},
|
blurContainer: {
|
||||||
blurContainer: {
|
borderTopLeftRadius: 24,
|
||||||
borderTopLeftRadius: 24,
|
borderTopRightRadius: 24,
|
||||||
borderTopRightRadius: 24,
|
overflow: "hidden",
|
||||||
overflow: "hidden",
|
},
|
||||||
},
|
content: {
|
||||||
content: {
|
paddingTop: 24,
|
||||||
paddingTop: 24,
|
paddingBottom: 50,
|
||||||
paddingBottom: 50,
|
overflow: "visible",
|
||||||
overflow: "visible",
|
},
|
||||||
},
|
title: {
|
||||||
title: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
fontWeight: "500",
|
||||||
fontWeight: "500",
|
color: "rgba(255,255,255,0.6)",
|
||||||
color: "rgba(255,255,255,0.6)",
|
marginBottom: 16,
|
||||||
marginBottom: 16,
|
paddingHorizontal: 48,
|
||||||
paddingHorizontal: 48,
|
textTransform: "uppercase",
|
||||||
textTransform: "uppercase",
|
letterSpacing: 1,
|
||||||
letterSpacing: 1,
|
},
|
||||||
},
|
scrollView: {
|
||||||
scrollView: {
|
overflow: "visible",
|
||||||
overflow: "visible",
|
},
|
||||||
},
|
scrollContent: {
|
||||||
scrollContent: {
|
paddingHorizontal: 48,
|
||||||
paddingHorizontal: 48,
|
paddingVertical: 20,
|
||||||
paddingVertical: 20,
|
gap: 12,
|
||||||
gap: 12,
|
},
|
||||||
},
|
cancelButtonContainer: {
|
||||||
cancelButtonContainer: {
|
marginTop: 16,
|
||||||
marginTop: 16,
|
paddingHorizontal: 48,
|
||||||
paddingHorizontal: 48,
|
alignItems: "flex-start",
|
||||||
alignItems: "flex-start",
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
||||||
|
|
||||||
export interface TVSeriesNavigationProps {
|
export interface TVSeriesNavigationProps {
|
||||||
@@ -16,7 +16,6 @@ export interface TVSeriesNavigationProps {
|
|||||||
|
|
||||||
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
||||||
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
|
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Only show for episodes with a series
|
// Only show for episodes with a series
|
||||||
@@ -28,7 +27,7 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Platform, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import {
|
|
||||||
GlassPosterView,
|
|
||||||
isGlassEffectAvailable,
|
|
||||||
} from "@/modules/glass-poster";
|
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSeriesSeasonCardProps {
|
export interface TVSeriesSeasonCardProps {
|
||||||
@@ -25,63 +21,9 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
onPress,
|
onPress,
|
||||||
hasTVPreferredFocus,
|
hasTVPreferredFocus,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
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 (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -96,16 +38,45 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
width: 210,
|
width: 210,
|
||||||
shadowColor: "#fff",
|
shadowColor: "#fff",
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
|
shadowOpacity: focused ? 0.5 : 0,
|
||||||
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
|
shadowRadius: focused ? 20 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{renderPoster()}
|
<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>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -119,7 +90,7 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused
|
color: focused
|
||||||
? "rgba(255,255,255,0.8)"
|
? "rgba(255,255,255,0.8)"
|
||||||
: "rgba(255,255,255,0.5)",
|
: "rgba(255,255,255,0.5)",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
|
import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
@@ -23,8 +23,6 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
View,
|
View,
|
||||||
TVSubtitleResultCardProps
|
TVSubtitleResultCardProps
|
||||||
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const styles = createStyles(typography);
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.03 });
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
||||||
|
|
||||||
@@ -199,73 +197,72 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
const styles = StyleSheet.create({
|
||||||
StyleSheet.create({
|
resultCard: {
|
||||||
resultCard: {
|
width: 220,
|
||||||
width: 220,
|
minHeight: 120,
|
||||||
minHeight: 120,
|
borderRadius: 14,
|
||||||
borderRadius: 14,
|
padding: 14,
|
||||||
padding: 14,
|
borderWidth: 1,
|
||||||
borderWidth: 1,
|
},
|
||||||
},
|
providerBadge: {
|
||||||
providerBadge: {
|
alignSelf: "flex-start",
|
||||||
alignSelf: "flex-start",
|
paddingHorizontal: 8,
|
||||||
paddingHorizontal: 8,
|
paddingVertical: 3,
|
||||||
paddingVertical: 3,
|
borderRadius: 6,
|
||||||
borderRadius: 6,
|
marginBottom: 8,
|
||||||
marginBottom: 8,
|
},
|
||||||
},
|
providerText: {
|
||||||
providerText: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
fontWeight: "600",
|
||||||
fontWeight: "600",
|
textTransform: "uppercase",
|
||||||
textTransform: "uppercase",
|
letterSpacing: 0.5,
|
||||||
letterSpacing: 0.5,
|
},
|
||||||
},
|
resultName: {
|
||||||
resultName: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
fontWeight: "500",
|
||||||
fontWeight: "500",
|
marginBottom: 8,
|
||||||
marginBottom: 8,
|
lineHeight: 18,
|
||||||
lineHeight: 18,
|
},
|
||||||
},
|
resultMeta: {
|
||||||
resultMeta: {
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
gap: 12,
|
||||||
gap: 12,
|
marginBottom: 8,
|
||||||
marginBottom: 8,
|
},
|
||||||
},
|
resultMetaText: {
|
||||||
resultMetaText: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
},
|
||||||
},
|
ratingContainer: {
|
||||||
ratingContainer: {
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
gap: 3,
|
||||||
gap: 3,
|
},
|
||||||
},
|
downloadCountContainer: {
|
||||||
downloadCountContainer: {
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
gap: 3,
|
||||||
gap: 3,
|
},
|
||||||
},
|
flagsContainer: {
|
||||||
flagsContainer: {
|
flexDirection: "row",
|
||||||
flexDirection: "row",
|
gap: 6,
|
||||||
gap: 6,
|
flexWrap: "wrap",
|
||||||
flexWrap: "wrap",
|
},
|
||||||
},
|
flag: {
|
||||||
flag: {
|
paddingHorizontal: 6,
|
||||||
paddingHorizontal: 6,
|
paddingVertical: 2,
|
||||||
paddingVertical: 2,
|
borderRadius: 4,
|
||||||
borderRadius: 4,
|
},
|
||||||
},
|
flagText: {
|
||||||
flagText: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
fontWeight: "600",
|
||||||
fontWeight: "600",
|
color: "#fff",
|
||||||
color: "#fff",
|
},
|
||||||
},
|
downloadingOverlay: {
|
||||||
downloadingOverlay: {
|
...StyleSheet.absoluteFillObject,
|
||||||
...StyleSheet.absoluteFillObject,
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
borderRadius: 14,
|
||||||
borderRadius: 14,
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable } from "react-native";
|
import { Animated, Pressable } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVTabButtonProps {
|
export interface TVTabButtonProps {
|
||||||
@@ -21,7 +21,6 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
|||||||
switchOnFocus = false,
|
switchOnFocus = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({
|
useTVFocusAnimation({
|
||||||
scaleAmount: 1.05,
|
scaleAmount: 1.05,
|
||||||
@@ -57,7 +56,7 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: focused || active ? "600" : "400",
|
fontWeight: focused || active ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVTechnicalDetailsProps {
|
export interface TVTechnicalDetailsProps {
|
||||||
mediaStreams: MediaStream[];
|
mediaStreams: MediaStream[];
|
||||||
@@ -11,7 +11,6 @@ export interface TVTechnicalDetailsProps {
|
|||||||
|
|
||||||
export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
||||||
({ mediaStreams }) => {
|
({ mediaStreams }) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const videoStream = mediaStreams.find((s) => s.Type === "Video");
|
const videoStream = mediaStreams.find((s) => s.Type === "Video");
|
||||||
@@ -25,7 +24,7 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.heading,
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
@@ -38,16 +37,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("common.video")}
|
Video
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{videoStream.DisplayTitle ||
|
{videoStream.DisplayTitle ||
|
||||||
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
|
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -57,16 +56,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("common.audio")}
|
Audio
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{audioStream.DisplayTitle ||
|
{audioStream.DisplayTitle ||
|
||||||
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
|
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVTrackCardProps {
|
export interface TVTrackCardProps {
|
||||||
@@ -15,8 +15,6 @@ export interface TVTrackCardProps {
|
|||||||
|
|
||||||
export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
||||||
({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const styles = createStyles(typography);
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
@@ -79,27 +77,26 @@ export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
const styles = StyleSheet.create({
|
||||||
StyleSheet.create({
|
trackCard: {
|
||||||
trackCard: {
|
width: 180,
|
||||||
width: 180,
|
height: 80,
|
||||||
height: 80,
|
borderRadius: 14,
|
||||||
borderRadius: 14,
|
justifyContent: "center",
|
||||||
justifyContent: "center",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
paddingHorizontal: 12,
|
||||||
paddingHorizontal: 12,
|
},
|
||||||
},
|
trackCardText: {
|
||||||
trackCardText: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
textAlign: "center",
|
||||||
textAlign: "center",
|
},
|
||||||
},
|
trackCardSublabel: {
|
||||||
trackCardSublabel: {
|
fontSize: TVTypography.callout,
|
||||||
fontSize: typography.callout,
|
marginTop: 2,
|
||||||
marginTop: 2,
|
},
|
||||||
},
|
checkmark: {
|
||||||
checkmark: {
|
position: "absolute",
|
||||||
position: "absolute",
|
top: 8,
|
||||||
top: 8,
|
right: 8,
|
||||||
right: 8,
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|||||||
@@ -25,12 +25,8 @@ export type { TVControlButtonProps } from "./TVControlButton";
|
|||||||
export { TVControlButton } from "./TVControlButton";
|
export { TVControlButton } from "./TVControlButton";
|
||||||
export type { TVFavoriteButtonProps } from "./TVFavoriteButton";
|
export type { TVFavoriteButtonProps } from "./TVFavoriteButton";
|
||||||
export { TVFavoriteButton } from "./TVFavoriteButton";
|
export { TVFavoriteButton } from "./TVFavoriteButton";
|
||||||
export type { TVFilterButtonProps } from "./TVFilterButton";
|
|
||||||
export { TVFilterButton } from "./TVFilterButton";
|
|
||||||
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
|
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
|
||||||
export { TVFocusablePoster } from "./TVFocusablePoster";
|
export { TVFocusablePoster } from "./TVFocusablePoster";
|
||||||
export type { TVItemCardTextProps } from "./TVItemCardText";
|
|
||||||
export { TVItemCardText } from "./TVItemCardText";
|
|
||||||
export type { TVLanguageCardProps } from "./TVLanguageCard";
|
export type { TVLanguageCardProps } from "./TVLanguageCard";
|
||||||
export { TVLanguageCard } from "./TVLanguageCard";
|
export { TVLanguageCard } from "./TVLanguageCard";
|
||||||
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVLogoutButtonProps {
|
export interface TVLogoutButtonProps {
|
||||||
@@ -15,7 +15,6 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVSectionHeaderProps {
|
export interface TVSectionHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => {
|
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => (
|
||||||
const typography = useScaledTVTypography();
|
<Text
|
||||||
|
style={{
|
||||||
return (
|
fontSize: TVTypography.callout,
|
||||||
<Text
|
fontWeight: "600",
|
||||||
style={{
|
color: "#9CA3AF",
|
||||||
fontSize: typography.callout,
|
textTransform: "uppercase",
|
||||||
fontWeight: "600",
|
letterSpacing: 1,
|
||||||
color: "#9CA3AF",
|
marginTop: 32,
|
||||||
textTransform: "uppercase",
|
marginBottom: 16,
|
||||||
letterSpacing: 1,
|
marginLeft: 8,
|
||||||
marginTop: 32,
|
}}
|
||||||
marginBottom: 16,
|
>
|
||||||
marginLeft: 8,
|
{title}
|
||||||
}}
|
</Text>
|
||||||
>
|
);
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsOptionButtonProps {
|
export interface TVSettingsOptionButtonProps {
|
||||||
@@ -20,7 +20,6 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
|
|||||||
isFirst,
|
isFirst,
|
||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||||
|
|
||||||
@@ -50,13 +49,13 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsRowProps {
|
export interface TVSettingsRowProps {
|
||||||
@@ -22,7 +22,6 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
|
|||||||
showChevron = true,
|
showChevron = true,
|
||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||||
|
|
||||||
@@ -52,13 +51,13 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginRight: showChevron ? 12 : 0,
|
marginRight: showChevron ? 12 : 0,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsStepperProps {
|
export interface TVSettingsStepperProps {
|
||||||
@@ -24,7 +24,6 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
|||||||
isFirst,
|
isFirst,
|
||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
|
const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||||
const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
||||||
const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
|
||||||
@@ -55,7 +54,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
|||||||
focusable={!disabled}
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View style={labelAnim.animatedStyle}>
|
<Animated.View style={labelAnim.animatedStyle}>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -90,7 +89,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
minWidth: 60,
|
minWidth: 60,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
import { Animated, Pressable, TextInput } from "react-native";
|
import { Animated, Pressable, TextInput } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsTextInputProps {
|
export interface TVSettingsTextInputProps {
|
||||||
@@ -23,7 +23,6 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
|||||||
secureTextEntry,
|
secureTextEntry,
|
||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||||
@@ -57,7 +56,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.callout,
|
fontSize: TVTypography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
@@ -75,7 +74,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
style={{
|
style={{
|
||||||
fontSize: typography.body,
|
fontSize: TVTypography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Animated, Pressable, View } from "react-native";
|
import { Animated, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsToggleProps {
|
export interface TVSettingsToggleProps {
|
||||||
@@ -19,7 +19,6 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
|
|||||||
isFirst,
|
isFirst,
|
||||||
disabled,
|
disabled,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
useTVFocusAnimation({ scaleAmount: 1.02 });
|
useTVFocusAnimation({ scaleAmount: 1.02 });
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { StyleSheet, TVFocusGuideView, View } from "react-native";
|
||||||
StyleSheet,
|
|
||||||
TVFocusGuideView,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
@@ -31,9 +26,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
|
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
|
||||||
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
|
||||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||||
@@ -88,96 +82,6 @@ interface Props {
|
|||||||
const TV_SEEKBAR_HEIGHT = 14;
|
const TV_SEEKBAR_HEIGHT = 14;
|
||||||
const TV_AUTO_HIDE_TIMEOUT = 5000;
|
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> = ({
|
export const Controls: FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
seek,
|
seek,
|
||||||
@@ -207,17 +111,8 @@ export const Controls: FC<Props> = ({
|
|||||||
playMethod,
|
playMethod,
|
||||||
transcodeReasons,
|
transcodeReasons,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
|
||||||
const { t } = useTranslation();
|
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 api = useAtomValue(apiAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -376,15 +271,6 @@ export const Controls: FC<Props> = ({
|
|||||||
isSeeking,
|
isSeeking,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
|
||||||
item.Id!,
|
|
||||||
currentTime,
|
|
||||||
seek,
|
|
||||||
_play,
|
|
||||||
false,
|
|
||||||
api,
|
|
||||||
);
|
|
||||||
|
|
||||||
const getFinishTime = () => {
|
const getFinishTime = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const finishTime = new Date(now.getTime() + remainingTime);
|
const finishTime = new Date(now.getTime() + remainingTime);
|
||||||
@@ -945,17 +831,15 @@ export const Controls: FC<Props> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.trickplayBubbleContainer}>
|
{showSeekBubble && (
|
||||||
<TVTrickplayBubblePositioned
|
<View style={styles.trickplayBubbleContainer}>
|
||||||
trickPlayUrl={trickPlayUrl}
|
<TrickplayBubble
|
||||||
trickplayInfo={trickplayInfo}
|
trickPlayUrl={trickPlayUrl}
|
||||||
time={seekBubbleTime}
|
trickplayInfo={trickplayInfo}
|
||||||
progress={effectiveProgress}
|
time={seekBubbleTime}
|
||||||
max={max}
|
/>
|
||||||
progressBarWidth={progressBarWidth}
|
</View>
|
||||||
visible={showSeekBubble}
|
)}
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Same padding as TVFocusableProgressBar for alignment */}
|
{/* Same padding as TVFocusableProgressBar for alignment */}
|
||||||
<View style={styles.minimalProgressWrapper}>
|
<View style={styles.minimalProgressWrapper}>
|
||||||
@@ -984,16 +868,14 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.timeContainer}>
|
<View style={styles.timeContainer}>
|
||||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
<Text style={styles.timeText}>
|
||||||
{formatTimeString(currentTime, "ms")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.timeRight}>
|
<View style={styles.timeRight}>
|
||||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
<Text style={styles.timeText}>
|
||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={styles.endsAtText}>
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
|
||||||
>
|
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -1019,18 +901,12 @@ export const Controls: FC<Props> = ({
|
|||||||
<View style={styles.metadataContainer}>
|
<View style={styles.metadataContainer}>
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<Text
|
<Text
|
||||||
style={[styles.subtitleText, { fontSize: typography.body }]}
|
style={styles.subtitleText}
|
||||||
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={[styles.titleText, { fontSize: typography.heading }]}>
|
<Text style={styles.titleText}>{item?.Name}</Text>
|
||||||
{item?.Name}
|
|
||||||
</Text>
|
|
||||||
{item?.Type === "Movie" && (
|
{item?.Type === "Movie" && (
|
||||||
<Text
|
<Text style={styles.subtitleText}>{item?.ProductionYear}</Text>
|
||||||
style={[styles.subtitleText, { fontSize: typography.body }]}
|
|
||||||
>
|
|
||||||
{item?.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1055,12 +931,6 @@ export const Controls: FC<Props> = ({
|
|||||||
disabled={false || !nextItem}
|
disabled={false || !nextItem}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
<TVControlButton
|
|
||||||
text='skip intro'
|
|
||||||
onPress={skipIntro}
|
|
||||||
disabled={!showSkipButton}
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.controlButtonsSpacer} />
|
<View style={styles.controlButtonsSpacer} />
|
||||||
|
|
||||||
@@ -1093,17 +963,15 @@ export const Controls: FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.trickplayBubbleContainer}>
|
{showSeekBubble && (
|
||||||
<TVTrickplayBubblePositioned
|
<View style={styles.trickplayBubbleContainer}>
|
||||||
trickPlayUrl={trickPlayUrl}
|
<TrickplayBubble
|
||||||
trickplayInfo={trickplayInfo}
|
trickPlayUrl={trickPlayUrl}
|
||||||
time={seekBubbleTime}
|
trickplayInfo={trickplayInfo}
|
||||||
progress={effectiveProgress}
|
time={seekBubbleTime}
|
||||||
max={max}
|
/>
|
||||||
progressBarWidth={progressBarWidth}
|
</View>
|
||||||
visible={showSeekBubble}
|
)}
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bidirectional focus guides - stacked together per docs */}
|
{/* Bidirectional focus guides - stacked together per docs */}
|
||||||
{/* Downward: play button → progress bar */}
|
{/* Downward: play button → progress bar */}
|
||||||
@@ -1135,16 +1003,14 @@ export const Controls: FC<Props> = ({
|
|||||||
</TVFocusGuideView>
|
</TVFocusGuideView>
|
||||||
|
|
||||||
<View style={styles.timeContainer}>
|
<View style={styles.timeContainer}>
|
||||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
<Text style={styles.timeText}>
|
||||||
{formatTimeString(currentTime, "ms")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.timeRight}>
|
<View style={styles.timeRight}>
|
||||||
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
<Text style={styles.timeText}>
|
||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={styles.endsAtText}>
|
||||||
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
|
||||||
>
|
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -1178,9 +1044,11 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
subtitleText: {
|
subtitleText: {
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
fontSize: TVTypography.body,
|
||||||
},
|
},
|
||||||
titleText: {
|
titleText: {
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
|
fontSize: TVTypography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
},
|
},
|
||||||
controlButtonsRow: {
|
controlButtonsRow: {
|
||||||
@@ -1195,15 +1063,12 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
trickplayBubbleContainer: {
|
trickplayBubbleContainer: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 170,
|
bottom: 120,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
|
alignItems: "center",
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
},
|
},
|
||||||
trickplayBubblePositioned: {
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
focusGuide: {
|
focusGuide: {
|
||||||
height: 1,
|
height: 1,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -1243,6 +1108,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
timeText: {
|
timeText: {
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
fontSize: TVTypography.body,
|
||||||
},
|
},
|
||||||
timeRight: {
|
timeRight: {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -1250,6 +1116,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
endsAtText: {
|
endsAtText: {
|
||||||
color: "rgba(255,255,255,0.5)",
|
color: "rgba(255,255,255,0.5)",
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
// Minimal seek bar styles
|
// Minimal seek bar styles
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Animated, {
|
|||||||
withTiming,
|
withTiming,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
import { TVTypography } from "@/constants/TVTypography";
|
||||||
import type { TechnicalInfo } from "@/modules/mpv-player";
|
import type { TechnicalInfo } from "@/modules/mpv-player";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { HEADER_LAYOUT } from "./constants";
|
import { HEADER_LAYOUT } from "./constants";
|
||||||
@@ -183,7 +183,6 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
currentSubtitleIndex,
|
currentSubtitleIndex,
|
||||||
currentAudioIndex,
|
currentAudioIndex,
|
||||||
}) => {
|
}) => {
|
||||||
const typography = useScaledTVTypography();
|
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
const [info, setInfo] = useState<TechnicalInfo | null>(null);
|
||||||
@@ -278,15 +277,8 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV
|
const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText;
|
||||||
? [
|
const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText;
|
||||||
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;
|
const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -391,7 +383,9 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
infoTextTV: {
|
infoTextTV: {
|
||||||
color: "white",
|
color: "white",
|
||||||
|
fontSize: TVTypography.body,
|
||||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||||
|
lineHeight: TVTypography.body * 1.5,
|
||||||
},
|
},
|
||||||
warningText: {
|
warningText: {
|
||||||
color: "#ff9800",
|
color: "#ff9800",
|
||||||
@@ -402,5 +396,6 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
reasonTextTV: {
|
reasonTextTV: {
|
||||||
color: "#fbbf24",
|
color: "#fbbf24",
|
||||||
|
fontSize: TVTypography.callout,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ import { View } from "react-native";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
|
|
||||||
const BASE_IMAGE_SCALE = 1.4;
|
|
||||||
const BUBBLE_LEFT_OFFSET = 62;
|
|
||||||
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
|
|
||||||
|
|
||||||
interface TrickplayBubbleProps {
|
interface TrickplayBubbleProps {
|
||||||
trickPlayUrl: {
|
trickPlayUrl: {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -26,21 +22,12 @@ interface TrickplayBubbleProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: 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> = ({
|
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
imageScale = 1,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!trickPlayUrl || !trickplayInfo) {
|
if (!trickPlayUrl || !trickplayInfo) {
|
||||||
return null;
|
return null;
|
||||||
@@ -49,17 +36,16 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
const { x, y, url } = trickPlayUrl;
|
const { x, y, url } = trickPlayUrl;
|
||||||
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
|
||||||
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
|
||||||
const finalScale = BASE_IMAGE_SCALE * imageScale;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: -BUBBLE_LEFT_OFFSET * imageScale,
|
left: -62,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
paddingTop: 30,
|
paddingTop: 30,
|
||||||
paddingBottom: 5,
|
paddingBottom: 5,
|
||||||
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
width: tileWidth * 1.5,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
@@ -69,13 +55,13 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
width: tileWidth,
|
width: tileWidth,
|
||||||
height: tileHeight,
|
height: tileHeight,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
transform: [{ scale: finalScale }],
|
transform: [{ scale: 1.4 }],
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
}}
|
}}
|
||||||
className='bg-neutral-800 overflow-hidden'
|
className='bg-neutral-800 overflow-hidden'
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
cachePolicy='memory-disk'
|
cachePolicy={"memory-disk"}
|
||||||
style={{
|
style={{
|
||||||
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
|
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
|
||||||
height:
|
height:
|
||||||
@@ -90,8 +76,15 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ marginTop: 30, fontSize: 16 }}>
|
<Text
|
||||||
{formatTime(time.hours, time.minutes, time.seconds)}
|
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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TV Typography Scale
|
* TV Typography Scale
|
||||||
*
|
*
|
||||||
@@ -25,29 +23,3 @@ export const TVTypography = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type TVTypographyKey = keyof typeof TVTypography;
|
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,7 +2,6 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
@@ -50,8 +49,6 @@ export const useItemQuery = (
|
|||||||
return response.data.Items?.[0];
|
return response.data.Items?.[0];
|
||||||
},
|
},
|
||||||
enabled: !!itemId,
|
enabled: !!itemId,
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
|
||||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"platforms": ["apple"],
|
|
||||||
"apple": {
|
|
||||||
"modules": ["GlassPosterModule"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "./GlassPoster.types";
|
|
||||||
export {
|
|
||||||
default as GlassPosterModule,
|
|
||||||
isGlassEffectAvailable,
|
|
||||||
} from "./GlassPosterModule";
|
|
||||||
export { default as GlassPosterView } from "./GlassPosterView";
|
|
||||||
@@ -7,9 +7,7 @@ export type {
|
|||||||
DownloadStartedEvent,
|
DownloadStartedEvent,
|
||||||
} from "./background-downloader";
|
} from "./background-downloader";
|
||||||
export { default as BackgroundDownloader } 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)
|
// MPV Player (iOS + Android)
|
||||||
export type {
|
export type {
|
||||||
AudioTrack as MpvAudioTrack,
|
AudioTrack as MpvAudioTrack,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"react-native-pager-view": "^6.9.1",
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-reanimated-carousel": "4.0.3",
|
"react-native-reanimated-carousel": "4.0.3",
|
||||||
|
"react-native-responsive-sizes": "^2.1.0",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.18.0",
|
"react-native-screens": "~4.18.0",
|
||||||
"react-native-svg": "15.12.1",
|
"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