mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-13 21:06:18 +00:00
Compare commits
22 Commits
feat/tv-in
...
feat/tv-in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
715daf1635 | ||
|
|
44caf4b1ff | ||
|
|
92c70fadd1 | ||
|
|
f637367b82 | ||
|
|
715764cef8 | ||
|
|
36d6686258 | ||
|
|
dca7cc99f2 | ||
|
|
875a017e8c | ||
|
|
0c6c20f563 | ||
|
|
2c9906377d | ||
|
|
d5f7a18fe5 | ||
|
|
4606b9718e | ||
|
|
c2d61654b0 | ||
|
|
2c6938c739 | ||
|
|
1f454c0f12 | ||
|
|
c215fda973 | ||
|
|
a852e2e769 | ||
|
|
29873e08d7 | ||
|
|
5ce5cc2d99 | ||
|
|
ae5a71ff29 | ||
|
|
0e3e8b8016 | ||
|
|
d07a521f60 |
@@ -30,4 +30,14 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
|
|||||||
|
|
||||||
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
|
||||||
|
|
||||||
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
|
||||||
|
|
||||||
|
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
|
||||||
|
|
||||||
|
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
|
||||||
|
|
||||||
|
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Home } from "../../../../components/home/Home";
|
import { 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,9 +15,14 @@ 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 { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
import {
|
||||||
|
AudioTranscodeMode,
|
||||||
|
TVTypographyScale,
|
||||||
|
useSettings,
|
||||||
|
} from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function SettingsTV() {
|
export default function SettingsTV() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -27,6 +32,7 @@ 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(
|
||||||
@@ -39,6 +45,8 @@ 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(
|
||||||
@@ -130,6 +138,33 @@ 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);
|
||||||
@@ -151,6 +186,11 @@ 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 }}>
|
||||||
@@ -166,7 +206,7 @@ export default function SettingsTV() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 42,
|
fontSize: typography.title,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -309,7 +349,7 @@ export default function SettingsTV() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
fontSize: 14,
|
fontSize: typography.callout - 2,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -333,7 +373,7 @@ export default function SettingsTV() {
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
fontSize: 12,
|
fontSize: typography.callout - 4,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -344,6 +384,18 @@ 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",
|
||||||
@@ -358,6 +410,18 @@ 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,14 +15,29 @@ 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, View } from "react-native";
|
import { FlatList, Platform, useWindowDimensions, 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 { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
import {
|
||||||
|
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 {
|
||||||
@@ -36,6 +51,10 @@ 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();
|
||||||
@@ -44,11 +63,15 @@ 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);
|
||||||
@@ -56,7 +79,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 } = useQuery({
|
const { data: collection, isLoading: isCollectionLoading } = useQuery({
|
||||||
queryKey: ["collection", collectionId],
|
queryKey: ["collection", collectionId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
@@ -71,6 +94,46 @@ 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]);
|
||||||
@@ -87,6 +150,18 @@ 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,
|
||||||
@@ -98,7 +173,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: 18,
|
limit: Platform.isTV ? 36 : 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]],
|
||||||
@@ -123,6 +198,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
collection,
|
collection,
|
||||||
|
collectionId,
|
||||||
selectedGenres,
|
selectedGenres,
|
||||||
selectedYears,
|
selectedYears,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -131,39 +207,40 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
queryKey: [
|
useInfiniteQuery({
|
||||||
"collection-items",
|
queryKey: [
|
||||||
collection,
|
"collection-items",
|
||||||
selectedGenres,
|
collectionId,
|
||||||
selectedYears,
|
selectedGenres,
|
||||||
selectedTags,
|
selectedYears,
|
||||||
sortBy,
|
selectedTags,
|
||||||
sortOrder,
|
sortBy,
|
||||||
],
|
sortOrder,
|
||||||
queryFn: fetchItems,
|
],
|
||||||
getNextPageParam: (lastPage, pages) => {
|
queryFn: fetchItems,
|
||||||
if (
|
getNextPageParam: (lastPage, pages) => {
|
||||||
!lastPage?.Items ||
|
if (
|
||||||
!lastPage?.TotalRecordCount ||
|
!lastPage?.Items ||
|
||||||
lastPage?.TotalRecordCount === 0
|
!lastPage?.TotalRecordCount ||
|
||||||
)
|
lastPage?.TotalRecordCount === 0
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
|
const accumulatedItems = pages.reduce(
|
||||||
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accumulatedItems < totalItems) {
|
||||||
|
return lastPage?.Items?.length * pages.length;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
},
|
||||||
const totalItems = lastPage.TotalRecordCount;
|
initialPageParam: 0,
|
||||||
const accumulatedItems = pages.reduce(
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
(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 (
|
||||||
@@ -195,7 +272,6 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ItemPoster item={item} />
|
<ItemPoster item={item} />
|
||||||
{/* <MoviePoster item={item} /> */}
|
|
||||||
<ItemCardText item={item} />
|
<ItemCardText item={item} />
|
||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
@@ -203,9 +279,38 @@ const page: React.FC = () => {
|
|||||||
[orientation],
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const renderTVItem = useCallback(
|
||||||
|
({ item }: { item: BaseItemDto }) => {
|
||||||
|
const handlePress = () => {
|
||||||
|
const navTarget = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navTarget as any);
|
||||||
|
};
|
||||||
|
|
||||||
const _insets = useSafeAreaInsets();
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginRight: TV_ITEM_GAP,
|
||||||
|
marginBottom: TV_ITEM_GAP,
|
||||||
|
width: TV_POSTER_WIDTH,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TVFocusablePoster onPress={handlePress}>
|
||||||
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
|
{(item.Type === "Series" || item.Type === "Episode") && (
|
||||||
|
<SeriesPoster item={item} />
|
||||||
|
)}
|
||||||
|
{item.Type !== "Movie" &&
|
||||||
|
item.Type !== "Series" &&
|
||||||
|
item.Type !== "Episode" && <MoviePoster item={item} />}
|
||||||
|
</TVFocusablePoster>
|
||||||
|
<TVItemCardText item={item} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
@@ -372,48 +477,315 @@ const page: React.FC = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TV Filter options - with "All" option for clearable filters
|
||||||
|
const tvGenreFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedGenres.length === 0,
|
||||||
|
},
|
||||||
|
...(tvGenreOptions || []).map((genre) => ({
|
||||||
|
label: genre,
|
||||||
|
value: genre,
|
||||||
|
selected: selectedGenres.includes(genre),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvGenreOptions, selectedGenres, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvYearFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedYears.length === 0,
|
||||||
|
},
|
||||||
|
...(tvYearOptions || []).map((year) => ({
|
||||||
|
label: String(year),
|
||||||
|
value: String(year),
|
||||||
|
selected: selectedYears.includes(String(year)),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvYearOptions, selectedYears, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvTagFilterOptions = useMemo(
|
||||||
|
(): TVOptionItem<string>[] => [
|
||||||
|
{
|
||||||
|
label: t("library.filters.all"),
|
||||||
|
value: "__all__",
|
||||||
|
selected: selectedTags.length === 0,
|
||||||
|
},
|
||||||
|
...(tvTagOptions || []).map((tag) => ({
|
||||||
|
label: tag,
|
||||||
|
value: tag,
|
||||||
|
selected: selectedTags.includes(tag),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[tvTagOptions, selectedTags, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortByOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
|
sortOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortBy[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const tvSortOrderOptions = useMemo(
|
||||||
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
|
sortOrderOptions.map((option) => ({
|
||||||
|
label: option.value,
|
||||||
|
value: option.key,
|
||||||
|
selected: sortOrder[0] === option.key,
|
||||||
|
})),
|
||||||
|
[sortOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
// TV Filter handlers using navigation-based modal
|
||||||
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.genres"),
|
||||||
|
options: tvGenreFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
} else if (selectedGenres.includes(value)) {
|
||||||
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
|
const handleShowYearFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.years"),
|
||||||
|
options: tvYearFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedYears([]);
|
||||||
|
} else if (selectedYears.includes(value)) {
|
||||||
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedYears([...selectedYears, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
|
const handleShowTagFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.tags"),
|
||||||
|
options: tvTagFilterOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
setSelectedTags([]);
|
||||||
|
} else if (selectedTags.includes(value)) {
|
||||||
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
|
} else {
|
||||||
|
setSelectedTags([...selectedTags, value]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_by"),
|
||||||
|
options: tvSortByOptions,
|
||||||
|
onSelect: (value: SortByOption) => {
|
||||||
|
setSortBy([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
// TV filter bar state
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedGenres.length > 0 ||
|
||||||
|
selectedYears.length > 0 ||
|
||||||
|
selectedTags.length > 0;
|
||||||
|
|
||||||
|
const resetAllFilters = useCallback(() => {
|
||||||
|
setSelectedGenres([]);
|
||||||
|
setSelectedYears([]);
|
||||||
|
setSelectedTags([]);
|
||||||
|
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
|
||||||
|
|
||||||
|
if (isLoading || isCollectionLoading) {
|
||||||
|
return (
|
||||||
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
return (
|
// Mobile return
|
||||||
<FlashList
|
if (!Platform.isTV) {
|
||||||
ListEmptyComponent={
|
return (
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
<FlashList
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
ListEmptyComponent={
|
||||||
{t("search.no_results")}
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
</View>
|
{t("search.no_results")}
|
||||||
}
|
</Text>
|
||||||
extraData={[
|
</View>
|
||||||
selectedGenres,
|
|
||||||
selectedYears,
|
|
||||||
selectedTags,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
]}
|
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
|
||||||
data={flatData}
|
|
||||||
renderItem={renderItem}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
numColumns={
|
|
||||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
|
||||||
}
|
|
||||||
onEndReached={() => {
|
|
||||||
if (hasNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
}
|
||||||
}}
|
extraData={[
|
||||||
onEndReachedThreshold={0.5}
|
selectedGenres,
|
||||||
ListHeaderComponent={ListHeaderComponent}
|
selectedYears,
|
||||||
contentContainerStyle={{ paddingBottom: 24 }}
|
selectedTags,
|
||||||
ItemSeparatorComponent={() => (
|
sortBy,
|
||||||
<View
|
sortOrder,
|
||||||
style={{
|
]}
|
||||||
width: 10,
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
height: 10,
|
data={flatData}
|
||||||
}}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
|
ListHeaderComponent={ListHeaderComponent}
|
||||||
|
contentContainerStyle={{ paddingBottom: 24 }}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV return with filter bar
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
marginTop: insets.top + 100,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingHorizontal: TV_SCALE_PADDING,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<TVFilterButton
|
||||||
|
label=''
|
||||||
|
value={t("library.filters.reset")}
|
||||||
|
onPress={resetAllFilters}
|
||||||
|
hasActiveFilter
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.genres")}
|
||||||
|
value={
|
||||||
|
selectedGenres.length > 0
|
||||||
|
? `${selectedGenres.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowGenreFilter}
|
||||||
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
<TVFilterButton
|
||||||
/>
|
label={t("library.filters.years")}
|
||||||
|
value={
|
||||||
|
selectedYears.length > 0
|
||||||
|
? `${selectedYears.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowYearFilter}
|
||||||
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.tags")}
|
||||||
|
value={
|
||||||
|
selectedTags.length > 0
|
||||||
|
? `${selectedTags.length} selected`
|
||||||
|
: t("library.filters.all")
|
||||||
|
}
|
||||||
|
onPress={handleShowTagFilter}
|
||||||
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_by")}
|
||||||
|
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
||||||
|
onPress={handleShowSortByFilter}
|
||||||
|
/>
|
||||||
|
<TVFilterButton
|
||||||
|
label={t("library.filters.sort_order")}
|
||||||
|
value={
|
||||||
|
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
||||||
|
}
|
||||||
|
onPress={handleShowSortOrderFilter}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<FlatList
|
||||||
|
key={`${orientation}-${nrOfCols}`}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
data={flatData}
|
||||||
|
renderItem={renderTVItem}
|
||||||
|
extraData={[orientation, nrOfCols]}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
numColumns={nrOfCols}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={1}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingBottom: 24,
|
||||||
|
paddingLeft: TV_SCALE_PADDING,
|
||||||
|
paddingRight: TV_SCALE_PADDING,
|
||||||
|
paddingTop: 20,
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,7 +118,8 @@ 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,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||||
enabled: isOffline || (!!api && !!user?.Id),
|
enabled: isOffline || (!!api && !!user?.Id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import type {
|
import type {
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
@@ -12,23 +11,13 @@ 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, {
|
import React, { useCallback, useEffect, useMemo } from "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,
|
||||||
@@ -48,9 +37,15 @@ 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 { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
|
import {
|
||||||
|
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 {
|
||||||
@@ -74,280 +69,11 @@ 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 = 16;
|
const TV_ITEM_GAP = 20;
|
||||||
const TV_SCALE_PADDING = 20;
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
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 {
|
||||||
@@ -358,6 +84,7 @@ 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();
|
||||||
@@ -380,13 +107,7 @@ 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({
|
||||||
@@ -511,12 +232,8 @@ const Page = () => {
|
|||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
if (Platform.isTV) {
|
||||||
// Calculate columns based on TV poster width + gap
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
|
return 1;
|
||||||
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;
|
||||||
@@ -682,7 +399,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
({ item }: { item: BaseItemDto }) => {
|
(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);
|
||||||
@@ -690,13 +407,12 @@ 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} disabled={isFilterModalOpen}>
|
<TVFocusablePoster onPress={handlePress}>
|
||||||
{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} />
|
||||||
@@ -709,7 +425,7 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router, isFilterModalOpen],
|
[router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
@@ -912,7 +628,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(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -928,7 +644,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvYearFilterOptions = useMemo(
|
const tvYearFilterOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -944,7 +660,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvTagFilterOptions = useMemo(
|
const tvTagFilterOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -960,7 +676,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvSortByOptions = useMemo(
|
const tvSortByOptions = useMemo(
|
||||||
(): TVFilterOption<SortByOption>[] =>
|
(): TVOptionItem<SortByOption>[] =>
|
||||||
sortOptions.map((option) => ({
|
sortOptions.map((option) => ({
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.key,
|
value: option.key,
|
||||||
@@ -970,7 +686,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvSortOrderOptions = useMemo(
|
const tvSortOrderOptions = useMemo(
|
||||||
(): TVFilterOption<SortOrderOption>[] =>
|
(): TVOptionItem<SortOrderOption>[] =>
|
||||||
sortOrderOptions.map((option) => ({
|
sortOrderOptions.map((option) => ({
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.key,
|
value: option.key,
|
||||||
@@ -980,7 +696,7 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const tvFilterByOptions = useMemo(
|
const tvFilterByOptions = useMemo(
|
||||||
(): TVFilterOption<string>[] => [
|
(): TVOptionItem<string>[] => [
|
||||||
{
|
{
|
||||||
label: t("library.filters.all"),
|
label: t("library.filters.all"),
|
||||||
value: "__all__",
|
value: "__all__",
|
||||||
@@ -995,56 +711,88 @@ const Page = () => {
|
|||||||
[filterBy, generalFilters, t],
|
[filterBy, generalFilters, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// TV Filter handlers
|
// TV Filter handlers using navigation-based modal
|
||||||
const handleGenreSelect = useCallback(
|
const handleShowGenreFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.genres"),
|
||||||
setSelectedGenres([]);
|
options: tvGenreFilterOptions,
|
||||||
} else if (selectedGenres.includes(value)) {
|
onSelect: (value: string) => {
|
||||||
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
if (value === "__all__") {
|
||||||
} else {
|
setSelectedGenres([]);
|
||||||
setSelectedGenres([...selectedGenres, value]);
|
} else if (selectedGenres.includes(value)) {
|
||||||
}
|
setSelectedGenres(selectedGenres.filter((g) => g !== value));
|
||||||
},
|
} else {
|
||||||
[selectedGenres, setSelectedGenres],
|
setSelectedGenres([...selectedGenres, value]);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
|
||||||
|
|
||||||
const handleYearSelect = useCallback(
|
const handleShowYearFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.years"),
|
||||||
setSelectedYears([]);
|
options: tvYearFilterOptions,
|
||||||
} else if (selectedYears.includes(value)) {
|
onSelect: (value: string) => {
|
||||||
setSelectedYears(selectedYears.filter((y) => y !== value));
|
if (value === "__all__") {
|
||||||
} else {
|
setSelectedYears([]);
|
||||||
setSelectedYears([...selectedYears, value]);
|
} else if (selectedYears.includes(value)) {
|
||||||
}
|
setSelectedYears(selectedYears.filter((y) => y !== value));
|
||||||
},
|
} else {
|
||||||
[selectedYears, setSelectedYears],
|
setSelectedYears([...selectedYears, value]);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
|
||||||
|
|
||||||
const handleTagSelect = useCallback(
|
const handleShowTagFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.tags"),
|
||||||
setSelectedTags([]);
|
options: tvTagFilterOptions,
|
||||||
} else if (selectedTags.includes(value)) {
|
onSelect: (value: string) => {
|
||||||
setSelectedTags(selectedTags.filter((t) => t !== value));
|
if (value === "__all__") {
|
||||||
} else {
|
setSelectedTags([]);
|
||||||
setSelectedTags([...selectedTags, value]);
|
} else if (selectedTags.includes(value)) {
|
||||||
}
|
setSelectedTags(selectedTags.filter((tag) => tag !== value));
|
||||||
},
|
} else {
|
||||||
[selectedTags, setSelectedTags],
|
setSelectedTags([...selectedTags, value]);
|
||||||
);
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
|
||||||
|
|
||||||
const handleFilterBySelect = useCallback(
|
const handleShowSortByFilter = useCallback(() => {
|
||||||
(value: string) => {
|
showOptions({
|
||||||
if (value === "__all__") {
|
title: t("library.filters.sort_by"),
|
||||||
_setFilterBy([]);
|
options: tvSortByOptions,
|
||||||
} else {
|
onSelect: (value: SortByOption) => {
|
||||||
setFilter([value as FilterByOption]);
|
setSortBy([value]);
|
||||||
}
|
},
|
||||||
},
|
});
|
||||||
[setFilter, _setFilterBy],
|
}, [showOptions, t, tvSortByOptions, setSortBy]);
|
||||||
);
|
|
||||||
|
const handleShowSortOrderFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.sort_order"),
|
||||||
|
options: tvSortOrderOptions,
|
||||||
|
onSelect: (value: SortOrderOption) => {
|
||||||
|
setSortOrder([value]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
|
||||||
|
|
||||||
|
const handleShowFilterByFilter = useCallback(() => {
|
||||||
|
showOptions({
|
||||||
|
title: t("library.filters.filter_by"),
|
||||||
|
options: tvFilterByOptions,
|
||||||
|
onSelect: (value: string) => {
|
||||||
|
if (value === "__all__") {
|
||||||
|
_setFilterBy([]);
|
||||||
|
} else {
|
||||||
|
setFilter([value as FilterByOption]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -1097,185 +845,134 @@ const Page = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
|
// TV return with filter bar
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1 }}>
|
<ScrollView
|
||||||
{/* Background content - disabled when modal is open */}
|
style={{ flex: 1 }}
|
||||||
<View
|
contentContainerStyle={{
|
||||||
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
|
paddingTop: insets.top + 100,
|
||||||
focusable={!isFilterModalOpen}
|
paddingBottom: insets.bottom + 60,
|
||||||
isTVSelectable={!isFilterModalOpen}
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
pointerEvents={isFilterModalOpen ? "none" : "auto"}
|
}}
|
||||||
accessibilityElementsHidden={isFilterModalOpen}
|
onScroll={({ nativeEvent }) => {
|
||||||
importantForAccessibility={
|
// Load more when near bottom
|
||||||
isFilterModalOpen ? "no-hide-descendants" : "auto"
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
|
const isNearBottom =
|
||||||
|
layoutMeasurement.height + contentOffset.y >=
|
||||||
|
contentSize.height - 500;
|
||||||
|
if (isNearBottom && hasNextPage && !isFetching) {
|
||||||
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
scrollEventThrottle={400}
|
||||||
|
>
|
||||||
|
{/* Filter bar */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingBottom: 24,
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
|
{hasActiveFilters && (
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
flexWrap: "nowrap",
|
|
||||||
marginTop: insets.top + 100,
|
|
||||||
paddingBottom: 8,
|
|
||||||
paddingHorizontal: TV_SCALE_PADDING,
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<TVFilterButton
|
|
||||||
label=''
|
|
||||||
value={t("library.filters.reset")}
|
|
||||||
onPress={resetAllFilters}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<TVFilterButton
|
<TVFilterButton
|
||||||
label={t("library.filters.genres")}
|
label=''
|
||||||
value={
|
value={t("library.filters.reset")}
|
||||||
selectedGenres.length > 0
|
onPress={resetAllFilters}
|
||||||
? `${selectedGenres.length} selected`
|
hasActiveFilter
|
||||||
: t("library.filters.all")
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("genre")}
|
|
||||||
hasTVPreferredFocus={!hasActiveFilters}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={selectedGenres.length > 0}
|
|
||||||
/>
|
/>
|
||||||
<TVFilterButton
|
)}
|
||||||
label={t("library.filters.years")}
|
<TVFilterButton
|
||||||
value={
|
label={t("library.filters.genres")}
|
||||||
selectedYears.length > 0
|
value={
|
||||||
? `${selectedYears.length} selected`
|
selectedGenres.length > 0
|
||||||
: t("library.filters.all")
|
? `${selectedGenres.length} selected`
|
||||||
}
|
: t("library.filters.all")
|
||||||
onPress={() => setOpenFilterModal("year")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={selectedYears.length > 0}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.tags")}
|
|
||||||
value={
|
|
||||||
selectedTags.length > 0
|
|
||||||
? `${selectedTags.length} selected`
|
|
||||||
: t("library.filters.all")
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("tags")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={selectedTags.length > 0}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.sort_by")}
|
|
||||||
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
|
|
||||||
onPress={() => setOpenFilterModal("sortBy")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.sort_order")}
|
|
||||||
value={
|
|
||||||
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("sortOrder")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
/>
|
|
||||||
<TVFilterButton
|
|
||||||
label={t("library.filters.filter_by")}
|
|
||||||
value={
|
|
||||||
filterBy.length > 0
|
|
||||||
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
|
|
||||||
: t("library.filters.all")
|
|
||||||
}
|
|
||||||
onPress={() => setOpenFilterModal("filterBy")}
|
|
||||||
disabled={isFiltersDisabled}
|
|
||||||
hasActiveFilter={filterBy.length > 0}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
|
|
||||||
<FlatList
|
|
||||||
key={`${orientation}-${nrOfCols}`}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View className='flex flex-col items-center justify-center h-full'>
|
|
||||||
<Text className='font-bold text-xl text-neutral-500'>
|
|
||||||
{t("library.no_results")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
contentInsetAdjustmentBehavior='automatic'
|
onPress={handleShowGenreFilter}
|
||||||
data={flatData}
|
hasTVPreferredFocus={!hasActiveFilters}
|
||||||
renderItem={renderTVItem}
|
hasActiveFilter={selectedGenres.length > 0}
|
||||||
extraData={[orientation, nrOfCols, isFilterModalOpen]}
|
/>
|
||||||
keyExtractor={keyExtractor}
|
<TVFilterButton
|
||||||
numColumns={nrOfCols}
|
label={t("library.filters.years")}
|
||||||
removeClippedSubviews={false}
|
value={
|
||||||
onEndReached={() => {
|
selectedYears.length > 0
|
||||||
if (hasNextPage) {
|
? `${selectedYears.length} selected`
|
||||||
fetchNextPage();
|
: t("library.filters.all")
|
||||||
}
|
}
|
||||||
}}
|
onPress={handleShowYearFilter}
|
||||||
onEndReachedThreshold={1}
|
hasActiveFilter={selectedYears.length > 0}
|
||||||
contentContainerStyle={{
|
/>
|
||||||
paddingBottom: 24,
|
<TVFilterButton
|
||||||
paddingLeft: TV_SCALE_PADDING,
|
label={t("library.filters.tags")}
|
||||||
paddingRight: TV_SCALE_PADDING,
|
value={
|
||||||
paddingTop: 20,
|
selectedTags.length > 0
|
||||||
}}
|
? `${selectedTags.length} selected`
|
||||||
ItemSeparatorComponent={() => (
|
: t("library.filters.all")
|
||||||
<View
|
}
|
||||||
style={{
|
onPress={handleShowTagFilter}
|
||||||
width: 10,
|
hasActiveFilter={selectedTags.length > 0}
|
||||||
height: 10,
|
/>
|
||||||
}}
|
<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>
|
</View>
|
||||||
|
|
||||||
{/* TV Filter Overlays */}
|
{/* Grid with flexWrap */}
|
||||||
<TVFilterSelector
|
{flatData.length === 0 ? (
|
||||||
visible={openFilterModal === "genre"}
|
<View
|
||||||
title={t("library.filters.genres")}
|
style={{
|
||||||
options={tvGenreFilterOptions}
|
flex: 1,
|
||||||
onSelect={handleGenreSelect}
|
justifyContent: "center",
|
||||||
onClose={() => setOpenFilterModal(null)}
|
alignItems: "center",
|
||||||
/>
|
paddingTop: 100,
|
||||||
<TVFilterSelector
|
}}
|
||||||
visible={openFilterModal === "year"}
|
>
|
||||||
title={t("library.filters.years")}
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
options={tvYearFilterOptions}
|
{t("library.no_results")}
|
||||||
onSelect={handleYearSelect}
|
</Text>
|
||||||
onClose={() => setOpenFilterModal(null)}
|
</View>
|
||||||
/>
|
) : (
|
||||||
<TVFilterSelector
|
<View
|
||||||
visible={openFilterModal === "tags"}
|
style={{
|
||||||
title={t("library.filters.tags")}
|
flexDirection: "row",
|
||||||
options={tvTagFilterOptions}
|
flexWrap: "wrap",
|
||||||
onSelect={handleTagSelect}
|
justifyContent: "center",
|
||||||
onClose={() => setOpenFilterModal(null)}
|
gap: TV_ITEM_GAP,
|
||||||
/>
|
}}
|
||||||
<TVFilterSelector
|
>
|
||||||
visible={openFilterModal === "sortBy"}
|
{flatData.map((item) => renderTVItem(item))}
|
||||||
title={t("library.filters.sort_by")}
|
</View>
|
||||||
options={tvSortByOptions}
|
)}
|
||||||
onSelect={(value) => setSortBy([value])}
|
|
||||||
onClose={() => setOpenFilterModal(null)}
|
{/* Loading indicator */}
|
||||||
/>
|
{isFetching && (
|
||||||
<TVFilterSelector
|
<View style={{ paddingVertical: 20 }}>
|
||||||
visible={openFilterModal === "sortOrder"}
|
<Loader />
|
||||||
title={t("library.filters.sort_order")}
|
</View>
|
||||||
options={tvSortOrderOptions}
|
)}
|
||||||
onSelect={(value) => setSortOrder([value])}
|
</ScrollView>
|
||||||
onClose={() => setOpenFilterModal(null)}
|
|
||||||
/>
|
|
||||||
<TVFilterSelector
|
|
||||||
visible={openFilterModal === "filterBy"}
|
|
||||||
title={t("library.filters.filter_by")}
|
|
||||||
options={tvFilterByOptions}
|
|
||||||
onSelect={handleFilterBySelect}
|
|
||||||
onClose={() => setOpenFilterModal(null)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Platform,
|
Platform,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
@@ -28,6 +29,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 useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import { useOrientation } from "@/hooks/useOrientation";
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
import {
|
import {
|
||||||
@@ -41,21 +43,36 @@ 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 = 16;
|
const TV_ITEM_GAP = 20;
|
||||||
const TV_SCALE_PADDING = 20;
|
const TV_HORIZONTAL_PADDING = 60;
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => (
|
||||||
<View style={{ marginTop: 12 }}>
|
<View style={{ marginTop: 12 }}>
|
||||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
<Text
|
||||||
|
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();
|
||||||
@@ -70,14 +87,8 @@ export default function WatchlistDetailScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
if (Platform.isTV) {
|
// TV uses flexWrap, so nrOfCols is just for mobile
|
||||||
// Calculate columns based on TV poster width + gap
|
if (Platform.isTV) 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;
|
||||||
if (screenWidth < 800) return 5;
|
if (screenWidth < 800) return 5;
|
||||||
@@ -185,7 +196,7 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTVItem = useCallback(
|
const renderTVItem = useCallback(
|
||||||
({ item, index }: { item: BaseItemDto; index: number }) => {
|
(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);
|
||||||
@@ -193,9 +204,8 @@ 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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -208,11 +218,11 @@ export default function WatchlistDetailScreen() {
|
|||||||
<SeriesPoster item={item} />
|
<SeriesPoster item={item} />
|
||||||
)}
|
)}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} />
|
<TVItemCardText item={item} typography={typography} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[router],
|
[router, typography],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -328,6 +338,120 @@ export default function WatchlistDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV layout with ScrollView + flexWrap
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: insets.top + 100,
|
||||||
|
paddingBottom: insets.bottom + 60,
|
||||||
|
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 32,
|
||||||
|
paddingBottom: 24,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{watchlist.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={20} color='#9ca3af' />
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{items?.length ?? 0}{" "}
|
||||||
|
{(items?.length ?? 0) === 1
|
||||||
|
? t("watchlists.item")
|
||||||
|
: t("watchlists.items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={
|
||||||
|
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
|
||||||
|
}
|
||||||
|
size={20}
|
||||||
|
color='#9ca3af'
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
|
||||||
|
{watchlist.isPublic
|
||||||
|
? t("watchlists.public")
|
||||||
|
: t("watchlists.private")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!isOwner && (
|
||||||
|
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
|
||||||
|
{t("watchlists.by_owner")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Grid with flexWrap */}
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='film-outline' size={48} color='#4b5563' />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("watchlists.empty_watchlist")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: TV_ITEM_GAP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => renderTVItem(item, index))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile layout with FlashList
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
@@ -340,14 +464,13 @@ export default function WatchlistDetailScreen() {
|
|||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingBottom: 24,
|
paddingBottom: 24,
|
||||||
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
|
paddingRight: insets.right,
|
||||||
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
|
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
}
|
}
|
||||||
renderItem={Platform.isTV ? renderTVItem : renderItem}
|
renderItem={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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +30,7 @@ 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();
|
||||||
@@ -336,8 +337,12 @@ export default function TVRequestModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
{t("jellyseerr.advanced")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{isDataLoaded && isReady ? (
|
{isDataLoaded && isReady ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -390,7 +395,12 @@ export default function TVRequestModalPage() {
|
|||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.buttonText}>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonText,
|
||||||
|
{ fontSize: typography.callout },
|
||||||
|
]}
|
||||||
|
>
|
||||||
{t("jellyseerr.request_button")}
|
{t("jellyseerr.request_button")}
|
||||||
</Text>
|
</Text>
|
||||||
</TVButton>
|
</TVButton>
|
||||||
@@ -451,13 +461,11 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -482,7 +490,6 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +162,7 @@ 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();
|
||||||
@@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
|
<Text style={[styles.heading, { fontSize: typography.heading }]}>
|
||||||
<Text style={styles.subtitle}>{modalState.title}</Text>
|
{t("jellyseerr.select_seasons")}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
|
||||||
|
{modalState.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{/* Season cards horizontal scroll */}
|
{/* Season cards horizontal scroll */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -343,7 +348,9 @@ export default function TVSeasonSelectModalPage() {
|
|||||||
color='#FFFFFF'
|
color='#FFFFFF'
|
||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.buttonText}>
|
<Text
|
||||||
|
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>
|
||||||
@@ -377,13 +384,11 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -413,7 +418,6 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
seasonTitle: {
|
seasonTitle: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
@@ -436,7 +440,6 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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();
|
||||||
@@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() {
|
|||||||
trapFocusRight
|
trapFocusRight
|
||||||
style={styles.content}
|
style={styles.content}
|
||||||
>
|
>
|
||||||
<Text style={styles.title}>{t("item_card.select_season")}</Text>
|
<Text style={[styles.title, { fontSize: typography.callout }]}>
|
||||||
|
{t("item_card.select_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
{isReady && (
|
{isReady && (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -164,7 +167,6 @@ 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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "streamyfin",
|
"name": "streamyfin",
|
||||||
@@ -77,7 +78,6 @@
|
|||||||
"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,8 +562,6 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -804,10 +802,6 @@
|
|||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"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=="],
|
||||||
@@ -1246,8 +1240,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1326,8 +1318,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1696,8 +1686,6 @@
|
|||||||
|
|
||||||
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
|
"react-native-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=="],
|
||||||
@@ -2292,8 +2280,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -2446,8 +2432,6 @@
|
|||||||
|
|
||||||
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"react-native-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=="],
|
||||||
@@ -2994,30 +2978,6 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -3202,26 +3162,6 @@
|
|||||||
|
|
||||||
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
"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=="],
|
||||||
@@ -3258,42 +3198,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"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=="],
|
||||||
@@ -3306,18 +3210,6 @@
|
|||||||
|
|
||||||
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
"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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
interface Props extends ViewProps {
|
||||||
@@ -16,6 +16,8 @@ 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>}
|
||||||
@@ -69,7 +71,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: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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";
|
||||||
|
|
||||||
@@ -141,15 +140,11 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl items-center justify-center
|
className={`rounded-2xl py-5 items-center justify-center
|
||||||
${colorClasses}
|
${colorClasses}
|
||||||
${className}`}
|
${className}`}
|
||||||
style={{ paddingVertical: size(50) }}
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||||
className={`${textColorClass} font-bold`}
|
|
||||||
style={{ fontSize: fontSize(10) }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ 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 = 340;
|
export const TV_LANDSCAPE_WIDTH = 400;
|
||||||
|
|
||||||
type ContinueWatchingPosterProps = {
|
type ContinueWatchingPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -32,34 +36,57 @@ 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=600&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&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=600&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
}
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Movie") {
|
if (item.Type === "Movie") {
|
||||||
if (item.ImageTags?.Thumb) {
|
if (item.ImageTags?.Thumb) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
}
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
}
|
}
|
||||||
if (item.Type === "Program") {
|
if (item.Type === "Program") {
|
||||||
if (item.ImageTags?.Thumb) {
|
if (item.ImageTags?.Thumb) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
}
|
}
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.ImageTags?.Thumb) {
|
if (item.ImageTags?.Thumb) {
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
|
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
|
||||||
}, [api, item, useEpisodePoster]);
|
}, [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
|
||||||
@@ -72,6 +99,39 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<View style={{ position: "relative" }}>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={url}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={24}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={isWatched}
|
||||||
|
isFocused={false}
|
||||||
|
width={TV_LANDSCAPE_WIDTH}
|
||||||
|
style={{ width: TV_LANDSCAPE_WIDTH }}
|
||||||
|
/>
|
||||||
|
{showPlayButton && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='play-circle' size={56} color='white' />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for older tvOS versions
|
||||||
return (
|
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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
interface TagProps {
|
interface TagProps {
|
||||||
@@ -25,6 +25,9 @@ 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>
|
||||||
@@ -60,7 +63,7 @@ export const Tag: React.FC<
|
|||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
|
<Text style={{ fontSize: typography.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 { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, 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,12 +17,14 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
|
import { Dimensions, ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { 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,
|
||||||
@@ -37,7 +39,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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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";
|
||||||
@@ -69,8 +71,9 @@ 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();
|
||||||
@@ -80,6 +83,31 @@ 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);
|
||||||
@@ -135,14 +163,13 @@ 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] = useState<View | null>(
|
const [_lastOptionButtonRef, setLastOptionButtonRef] =
|
||||||
null,
|
useState<View | null>(null);
|
||||||
);
|
|
||||||
|
|
||||||
// Get available audio tracks
|
// Get available audio tracks
|
||||||
const audioTracks = useMemo(() => {
|
const audioTracks = useMemo(() => {
|
||||||
@@ -386,6 +413,18 @@ 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 =
|
||||||
@@ -427,6 +466,14 @@ 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 (
|
||||||
@@ -456,36 +503,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
minHeight: SCREEN_HEIGHT * 0.45,
|
minHeight: SCREEN_HEIGHT * 0.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Poster */}
|
{/* Left side - Content */}
|
||||||
<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 ? (
|
||||||
@@ -502,7 +520,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.display,
|
fontSize: typography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
@@ -518,7 +536,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 16 }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.title,
|
fontSize: typography.title,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
@@ -527,7 +545,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "white",
|
color: "white",
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
@@ -572,7 +590,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
@@ -605,7 +623,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -714,14 +732,6 @@ 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
|
||||||
@@ -733,11 +743,112 @@ 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 }}>
|
||||||
{/* Cast & Crew (text version) */}
|
{/* Season Episodes - Episode only */}
|
||||||
|
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
|
||||||
|
<View style={{ marginBottom: 40 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.heading,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("item_card.more_from_this_season")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ marginHorizontal: -80, overflow: "visible" }}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingHorizontal: 80,
|
||||||
|
paddingVertical: 12,
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{seasonEpisodes.map((episode, index) => (
|
||||||
|
<TVEpisodeCard
|
||||||
|
key={episode.Id}
|
||||||
|
episode={episode}
|
||||||
|
onPress={() => handleEpisodePress(episode)}
|
||||||
|
disabled={episode.Id === item.Id}
|
||||||
|
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* From this Series - Episode only */}
|
||||||
|
<TVSeriesNavigation
|
||||||
|
item={item}
|
||||||
|
seriesImageUrl={seriesImageUrl}
|
||||||
|
seasonImageUrl={seasonImageUrl}
|
||||||
|
onSeriesPress={handleSeriesPress}
|
||||||
|
onSeasonPress={handleSeasonPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
|
||||||
|
{showVisualCast && (
|
||||||
|
<TVCastSection
|
||||||
|
cast={fullCast}
|
||||||
|
apiBasePath={api?.basePath}
|
||||||
|
onActorPress={handleActorPress}
|
||||||
|
firstActorRefSetter={setFirstActorCardRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cast & Crew (text version - director, etc.) */}
|
||||||
<TVCastCrewText
|
<TVCastCrewText
|
||||||
director={director}
|
director={director}
|
||||||
cast={cast}
|
cast={cast}
|
||||||
@@ -751,26 +862,6 @@ 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,41 +1,28 @@
|
|||||||
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: 180,
|
paddingTop: insets.top + 140,
|
||||||
paddingHorizontal: 160,
|
paddingHorizontal: insets.left + 80,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left side - Poster placeholder */}
|
{/* Left side - Content placeholders */}
|
||||||
<View
|
<View style={{ flex: 1 }}>
|
||||||
style={{
|
{/* Logo placeholder */}
|
||||||
width: SCREEN_WIDTH * 0.22,
|
|
||||||
marginRight: 50,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: 2 / 3,
|
height: 150,
|
||||||
borderRadius: 16,
|
width: "80%",
|
||||||
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,
|
||||||
@@ -155,6 +142,22 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,909 +0,0 @@
|
|||||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Pressable,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
||||||
import Animated, {
|
|
||||||
Easing,
|
|
||||||
interpolate,
|
|
||||||
runOnJS,
|
|
||||||
type SharedValue,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
|
||||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
|
||||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
|
||||||
import { ItemImage } from "../common/ItemImage";
|
|
||||||
import { getItemNavigation } from "../common/TouchableItemRouter";
|
|
||||||
import type { SelectedOptions } from "../ItemContent";
|
|
||||||
import { PlayButton } from "../PlayButton";
|
|
||||||
import { MarkAsPlayedLargeButton } from "./MarkAsPlayedLargeButton";
|
|
||||||
|
|
||||||
interface AppleTVCarouselProps {
|
|
||||||
initialIndex?: number;
|
|
||||||
onItemChange?: (index: number) => void;
|
|
||||||
scrollOffset?: SharedValue<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout Constants
|
|
||||||
const GRADIENT_HEIGHT_TOP = 150;
|
|
||||||
const GRADIENT_HEIGHT_BOTTOM = 150;
|
|
||||||
const LOGO_HEIGHT = 80;
|
|
||||||
|
|
||||||
// Position Constants
|
|
||||||
const LOGO_BOTTOM_POSITION = 260;
|
|
||||||
const GENRES_BOTTOM_POSITION = 220;
|
|
||||||
const OVERVIEW_BOTTOM_POSITION = 165;
|
|
||||||
const CONTROLS_BOTTOM_POSITION = 80;
|
|
||||||
const DOTS_BOTTOM_POSITION = 40;
|
|
||||||
|
|
||||||
// Size Constants
|
|
||||||
const DOT_HEIGHT = 6;
|
|
||||||
const DOT_ACTIVE_WIDTH = 20;
|
|
||||||
const DOT_INACTIVE_WIDTH = 12;
|
|
||||||
const PLAY_BUTTON_SKELETON_HEIGHT = 50;
|
|
||||||
const PLAYED_STATUS_SKELETON_SIZE = 40;
|
|
||||||
const TEXT_SKELETON_HEIGHT = 20;
|
|
||||||
const TEXT_SKELETON_WIDTH = 250;
|
|
||||||
const OVERVIEW_SKELETON_HEIGHT = 16;
|
|
||||||
const OVERVIEW_SKELETON_WIDTH = 400;
|
|
||||||
const _EMPTY_STATE_ICON_SIZE = 64;
|
|
||||||
|
|
||||||
// Spacing Constants
|
|
||||||
const HORIZONTAL_PADDING = 40;
|
|
||||||
const DOT_PADDING = 2;
|
|
||||||
const DOT_GAP = 4;
|
|
||||||
const CONTROLS_GAP = 10;
|
|
||||||
const _TEXT_MARGIN_TOP = 16;
|
|
||||||
|
|
||||||
// Border Radius Constants
|
|
||||||
const DOT_BORDER_RADIUS = 3;
|
|
||||||
const LOGO_SKELETON_BORDER_RADIUS = 8;
|
|
||||||
const TEXT_SKELETON_BORDER_RADIUS = 4;
|
|
||||||
const PLAY_BUTTON_BORDER_RADIUS = 25;
|
|
||||||
const PLAYED_STATUS_BORDER_RADIUS = 20;
|
|
||||||
|
|
||||||
// Animation Constants
|
|
||||||
const DOT_ANIMATION_DURATION = 300;
|
|
||||||
const CAROUSEL_TRANSITION_DURATION = 250;
|
|
||||||
const PAN_ACTIVE_OFFSET = 10;
|
|
||||||
const TRANSLATION_THRESHOLD = 0.2;
|
|
||||||
const VELOCITY_THRESHOLD = 400;
|
|
||||||
|
|
||||||
// Text Constants
|
|
||||||
const GENRES_FONT_SIZE = 16;
|
|
||||||
const OVERVIEW_FONT_SIZE = 14;
|
|
||||||
const _EMPTY_STATE_FONT_SIZE = 18;
|
|
||||||
const TEXT_SHADOW_RADIUS = 2;
|
|
||||||
const MAX_GENRES_COUNT = 2;
|
|
||||||
const MAX_BUTTON_WIDTH = 300;
|
|
||||||
const OVERVIEW_MAX_LINES = 2;
|
|
||||||
const OVERVIEW_MAX_WIDTH = "80%";
|
|
||||||
|
|
||||||
// Opacity Constants
|
|
||||||
const OVERLAY_OPACITY = 0.3;
|
|
||||||
const DOT_INACTIVE_OPACITY = 0.6;
|
|
||||||
const TEXT_OPACITY = 0.9;
|
|
||||||
|
|
||||||
// Color Constants
|
|
||||||
const SKELETON_BACKGROUND_COLOR = "#1a1a1a";
|
|
||||||
const SKELETON_ELEMENT_COLOR = "#333";
|
|
||||||
const SKELETON_ACTIVE_DOT_COLOR = "#666";
|
|
||||||
const _EMPTY_STATE_COLOR = "#666";
|
|
||||||
const TEXT_SHADOW_COLOR = "rgba(0, 0, 0, 0.8)";
|
|
||||||
const LOGO_WIDTH_PERCENTAGE = "80%";
|
|
||||||
|
|
||||||
const DotIndicator = ({
|
|
||||||
index,
|
|
||||||
currentIndex,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
index: number;
|
|
||||||
currentIndex: number;
|
|
||||||
onPress: (index: number) => void;
|
|
||||||
}) => {
|
|
||||||
const isActive = index === currentIndex;
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
width: withTiming(isActive ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
opacity: withTiming(isActive ? 1 : DOT_INACTIVE_OPACITY, {
|
|
||||||
duration: DOT_ANIMATION_DURATION,
|
|
||||||
easing: Easing.out(Easing.quad),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onPress(index)}
|
|
||||||
style={{
|
|
||||||
padding: DOT_PADDING, // Increase touch area
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor: isActive ? "white" : "rgba(255, 255, 255, 0.4)",
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
|
||||||
initialIndex = 0,
|
|
||||||
onItemChange,
|
|
||||||
scrollOffset,
|
|
||||||
}) => {
|
|
||||||
const { settings } = useSettings();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const { isConnected, serverConnected } = useNetworkStatus();
|
|
||||||
const router = useRouter();
|
|
||||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
||||||
const isLandscape = screenWidth >= screenHeight;
|
|
||||||
const carouselHeight = useMemo(
|
|
||||||
() => (isLandscape ? screenHeight * 0.9 : screenHeight / 1.45),
|
|
||||||
[isLandscape, screenHeight],
|
|
||||||
);
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
||||||
const translateX = useSharedValue(-initialIndex * screenWidth);
|
|
||||||
|
|
||||||
const isQueryEnabled =
|
|
||||||
!!api && !!user?.Id && isConnected && serverConnected === true;
|
|
||||||
|
|
||||||
const { data: continueWatchingData, isLoading: continueWatchingLoading } =
|
|
||||||
useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "continueWatching", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: nextUpData, isLoading: nextUpLoading } = useQuery({
|
|
||||||
queryKey: ["appleTVCarousel", "nextUp", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres", "Overview"],
|
|
||||||
limit: 2,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: recentlyAddedData, isLoading: recentlyAddedLoading } = useQuery(
|
|
||||||
{
|
|
||||||
queryKey: ["appleTVCarousel", "recentlyAdded", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
const response = await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user.Id,
|
|
||||||
limit: 2,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres", "Overview"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
});
|
|
||||||
return response.data || [];
|
|
||||||
},
|
|
||||||
enabled: isQueryEnabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const continueItems = continueWatchingData ?? [];
|
|
||||||
const nextItems = nextUpData ?? [];
|
|
||||||
const recentItems = recentlyAddedData ?? [];
|
|
||||||
|
|
||||||
const allItems = [
|
|
||||||
...continueItems.slice(0, 2),
|
|
||||||
...nextItems.slice(0, 2),
|
|
||||||
...recentItems.slice(0, 2),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deduplicate by item ID to prevent duplicate keys
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return allItems.filter((item) => {
|
|
||||||
if (item.Id && !seen.has(item.Id)) {
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}, [continueWatchingData, nextUpData, recentlyAddedData]);
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
continueWatchingLoading || nextUpLoading || recentlyAddedLoading;
|
|
||||||
const hasItems = items.length > 0;
|
|
||||||
|
|
||||||
// Only get play settings if we have valid items
|
|
||||||
const currentItem = hasItems ? items[currentIndex] : null;
|
|
||||||
|
|
||||||
// Extract colors for the current item only (for performance)
|
|
||||||
const currentItemColors = useImageColorsReturn({ item: currentItem });
|
|
||||||
|
|
||||||
// Create a fallback empty item for useDefaultPlaySettings when no item is available
|
|
||||||
const itemForPlaySettings = currentItem || { MediaSources: [] };
|
|
||||||
const {
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultMediaSource,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
} = useDefaultPlaySettings(itemForPlaySettings as BaseItemDto, settings);
|
|
||||||
|
|
||||||
const [selectedOptions, setSelectedOptions] = useState<
|
|
||||||
SelectedOptions | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only set options if we have valid current item
|
|
||||||
if (currentItem) {
|
|
||||||
setSelectedOptions({
|
|
||||||
bitrate: defaultBitrate,
|
|
||||||
mediaSource: defaultMediaSource ?? undefined,
|
|
||||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
|
||||||
audioIndex: defaultAudioIndex,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSelectedOptions(undefined);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
defaultAudioIndex,
|
|
||||||
defaultBitrate,
|
|
||||||
defaultSubtitleIndex,
|
|
||||||
defaultMediaSource,
|
|
||||||
currentIndex,
|
|
||||||
currentItem,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasItems) {
|
|
||||||
setCurrentIndex(initialIndex);
|
|
||||||
translateX.value = -initialIndex * screenWidth;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentIndex((prev) => {
|
|
||||||
const newIndex = Math.min(prev, items.length - 1);
|
|
||||||
translateX.value = -newIndex * screenWidth;
|
|
||||||
return newIndex;
|
|
||||||
});
|
|
||||||
}, [hasItems, items, initialIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
translateX.value = -currentIndex * screenWidth;
|
|
||||||
}, [currentIndex, screenWidth, translateX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasItems) {
|
|
||||||
onItemChange?.(currentIndex);
|
|
||||||
}
|
|
||||||
}, [hasItems, currentIndex, onItemChange]);
|
|
||||||
|
|
||||||
const goToIndex = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (!hasItems || index < 0 || index >= items.length) return;
|
|
||||||
|
|
||||||
translateX.value = withTiming(-index * screenWidth, {
|
|
||||||
duration: CAROUSEL_TRANSITION_DURATION, // Slightly longer for smoother feel
|
|
||||||
easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // iOS-like smooth deceleration curve
|
|
||||||
});
|
|
||||||
|
|
||||||
setCurrentIndex(index);
|
|
||||||
onItemChange?.(index);
|
|
||||||
},
|
|
||||||
[hasItems, items, onItemChange, screenWidth, translateX],
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigateToItem = useCallback(
|
|
||||||
(item: BaseItemDto) => {
|
|
||||||
const navigation = getItemNavigation(item, "(home)");
|
|
||||||
router.push(navigation as any);
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
);
|
|
||||||
|
|
||||||
const panGesture = Gesture.Pan()
|
|
||||||
.activeOffsetX([-PAN_ACTIVE_OFFSET, PAN_ACTIVE_OFFSET])
|
|
||||||
.onUpdate((event) => {
|
|
||||||
translateX.value = -currentIndex * screenWidth + event.translationX;
|
|
||||||
})
|
|
||||||
.onEnd((event) => {
|
|
||||||
const velocity = event.velocityX;
|
|
||||||
const translation = event.translationX;
|
|
||||||
|
|
||||||
let newIndex = currentIndex;
|
|
||||||
|
|
||||||
// Improved thresholds for more responsive navigation
|
|
||||||
if (
|
|
||||||
Math.abs(translation) > screenWidth * TRANSLATION_THRESHOLD ||
|
|
||||||
Math.abs(velocity) > VELOCITY_THRESHOLD
|
|
||||||
) {
|
|
||||||
if (translation > 0 && currentIndex > 0) {
|
|
||||||
newIndex = currentIndex - 1;
|
|
||||||
} else if (
|
|
||||||
translation < 0 &&
|
|
||||||
items &&
|
|
||||||
currentIndex < items.length - 1
|
|
||||||
) {
|
|
||||||
newIndex = currentIndex + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOnJS(goToIndex)(newIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [{ translateX: translateX.value }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePlayedStatus = useMarkAsPlayed(items);
|
|
||||||
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (!scrollOffset) return {};
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[-carouselHeight / 2, 0, carouselHeight * 0.75],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-carouselHeight, 0, carouselHeight],
|
|
||||||
[2, 1, 1],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderDots = () => {
|
|
||||||
if (!hasItems || items.length <= 1) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((_, index) => (
|
|
||||||
<DotIndicator
|
|
||||||
key={index}
|
|
||||||
index={index}
|
|
||||||
currentIndex={currentIndex}
|
|
||||||
onPress={goToIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderSkeletonLoader = () => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: SKELETON_BACKGROUND_COLOR,
|
|
||||||
position: "absolute",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dark Overlay Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Top Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.8)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black Bottom Skeleton */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: LOGO_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Type and Genres Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: TEXT_SKELETON_HEIGHT,
|
|
||||||
width: TEXT_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: OVERVIEW_SKELETON_HEIGHT,
|
|
||||||
width: OVERVIEW_SKELETON_WIDTH * 0.7,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: TEXT_SKELETON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Controls Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: PLAY_BUTTON_SKELETON_HEIGHT,
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: MAX_BUTTON_WIDTH,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAY_BUTTON_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Played Status Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
height: PLAYED_STATUS_SKELETON_SIZE,
|
|
||||||
backgroundColor: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: PLAYED_STATUS_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Dots Skeleton */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: DOTS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: DOT_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[1, 2, 3].map((_, index) => (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
width: index === 0 ? DOT_ACTIVE_WIDTH : DOT_INACTIVE_WIDTH,
|
|
||||||
height: DOT_HEIGHT,
|
|
||||||
backgroundColor:
|
|
||||||
index === 0
|
|
||||||
? SKELETON_ACTIVE_DOT_COLOR
|
|
||||||
: SKELETON_ELEMENT_COLOR,
|
|
||||||
borderRadius: DOT_BORDER_RADIUS,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItem = (item: BaseItemDto, _index: number) => {
|
|
||||||
const itemLogoUrl = api ? getLogoImageUrlById({ api, item }) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={item.Id}
|
|
||||||
style={{
|
|
||||||
width: screenWidth,
|
|
||||||
height: carouselHeight,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background Backdrop */}
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "absolute",
|
|
||||||
},
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<ItemImage
|
|
||||||
item={item}
|
|
||||||
variant='Backdrop'
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Dark Overlay */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: `rgba(0, 0, 0, ${OVERLAY_OPACITY})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Top */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["rgba(0,0,0,1)", "rgba(0,0,0,0.2)", "transparent"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: GRADIENT_HEIGHT_TOP,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradient Fade to Black at Bottom */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={["transparent", "rgba(0,0,0,0.8)", "rgba(0,0,0,1)"]}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: GRADIENT_HEIGHT_BOTTOM,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Logo Section */}
|
|
||||||
{itemLogoUrl && (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => navigateToItem(item)}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: LOGO_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
source={{
|
|
||||||
uri: itemLogoUrl,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
height: LOGO_HEIGHT,
|
|
||||||
width: LOGO_WIDTH_PERCENTAGE,
|
|
||||||
}}
|
|
||||||
contentFit='contain'
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Type and Genres Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: GENRES_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY})`,
|
|
||||||
fontSize: GENRES_FONT_SIZE,
|
|
||||||
fontWeight: "500",
|
|
||||||
textAlign: "center",
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
let typeLabel = "";
|
|
||||||
|
|
||||||
if (item.Type === "Episode") {
|
|
||||||
// For episodes, show season and episode number
|
|
||||||
const season = item.ParentIndexNumber;
|
|
||||||
const episode = item.IndexNumber;
|
|
||||||
if (season && episode) {
|
|
||||||
typeLabel = `S${season} • E${episode}`;
|
|
||||||
} else {
|
|
||||||
typeLabel = "Episode";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
typeLabel =
|
|
||||||
item.Type === "Series"
|
|
||||||
? "TV Show"
|
|
||||||
: item.Type === "Movie"
|
|
||||||
? "Movie"
|
|
||||||
: item.Type || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const genres =
|
|
||||||
item.Genres && item.Genres.length > 0
|
|
||||||
? item.Genres.slice(0, MAX_GENRES_COUNT).join(" • ")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
if (typeLabel && genres) {
|
|
||||||
return `${typeLabel} • ${genres}`;
|
|
||||||
} else if (typeLabel) {
|
|
||||||
return typeLabel;
|
|
||||||
} else if (genres) {
|
|
||||||
return genres;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Overview Section - for Episodes and Movies */}
|
|
||||||
{(item.Type === "Episode" || item.Type === "Movie") &&
|
|
||||||
item.Overview && (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: OVERVIEW_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TouchableOpacity onPress={() => navigateToItem(item)}>
|
|
||||||
<Animated.Text
|
|
||||||
numberOfLines={OVERVIEW_MAX_LINES}
|
|
||||||
style={{
|
|
||||||
color: `rgba(255, 255, 255, ${TEXT_OPACITY * 0.85})`,
|
|
||||||
fontSize: OVERVIEW_FONT_SIZE,
|
|
||||||
fontWeight: "400",
|
|
||||||
textAlign: "center",
|
|
||||||
maxWidth: OVERVIEW_MAX_WIDTH,
|
|
||||||
textShadowColor: TEXT_SHADOW_COLOR,
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: TEXT_SHADOW_RADIUS,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.Overview}
|
|
||||||
</Animated.Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Controls Section */}
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: CONTROLS_BOTTOM_POSITION,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
paddingHorizontal: HORIZONTAL_PADDING,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: CONTROLS_GAP,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Play Button */}
|
|
||||||
<View style={{ flex: 1, maxWidth: MAX_BUTTON_WIDTH }}>
|
|
||||||
{selectedOptions && (
|
|
||||||
<PlayButton
|
|
||||||
item={item}
|
|
||||||
selectedOptions={selectedOptions}
|
|
||||||
colors={currentItemColors}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Mark as Played */}
|
|
||||||
<MarkAsPlayedLargeButton
|
|
||||||
isPlayed={item.UserData?.Played ?? false}
|
|
||||||
onToggle={togglePlayedStatus}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight,
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderSkeletonLoader()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty items
|
|
||||||
if (!hasItems) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
backgroundColor: "#000",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GestureDetector gesture={panGesture}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
height: carouselHeight, // Fixed height instead of flex: 1
|
|
||||||
flexDirection: "row",
|
|
||||||
width: screenWidth * items.length,
|
|
||||||
},
|
|
||||||
containerAnimatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => renderItem(item, index))}
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
|
|
||||||
{/* Animated Dots Indicator */}
|
|
||||||
{renderDots()}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Button, Host } from "@expo/ui/swift-ui";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Platform, View } from "react-native";
|
|
||||||
import { RoundButton } from "../RoundButton";
|
|
||||||
|
|
||||||
interface MarkAsPlayedLargeButtonProps {
|
|
||||||
isPlayed: boolean;
|
|
||||||
onToggle: (isPlayed: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MarkAsPlayedLargeButton: React.FC<
|
|
||||||
MarkAsPlayedLargeButtonProps
|
|
||||||
> = ({ isPlayed, onToggle }) => {
|
|
||||||
if (Platform.OS === "ios")
|
|
||||||
return (
|
|
||||||
<Host
|
|
||||||
style={{
|
|
||||||
flex: 0,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
flexDirection: "row",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onPress={() => onToggle(isPlayed)} variant='glass'>
|
|
||||||
<View>
|
|
||||||
<Ionicons
|
|
||||||
name='checkmark'
|
|
||||||
size={24}
|
|
||||||
color='white'
|
|
||||||
style={{
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Button>
|
|
||||||
</Host>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<RoundButton
|
|
||||||
size='large'
|
|
||||||
icon={isPlayed ? "checkmark" : "checkmark"}
|
|
||||||
onPress={() => onToggle(isPlayed)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
|
|||||||
if (Platform.isTV)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
<RNText
|
<RNText
|
||||||
allowFontScaling={true}
|
allowFontScaling={false}
|
||||||
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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
@@ -28,6 +28,7 @@ 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);
|
||||||
@@ -148,7 +149,7 @@ export const Favorites = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -160,7 +161,7 @@ export const Favorites = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -598,11 +598,14 @@ 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 Continue Watching and Next Up
|
// Render Streamystats sections after Recently Added sections
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
// For custom sections: place at the very end
|
||||||
? 0
|
const hasSuggestedMovies =
|
||||||
: 1;
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
const streamystatsIndex =
|
||||||
|
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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";
|
||||||
@@ -41,8 +42,8 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
// Reduced gap since sections have internal padding for scale animations
|
// Generous gap between sections for Apple TV+ aesthetic
|
||||||
const SECTION_GAP = 10;
|
const SECTION_GAP = 24;
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
type InfiniteScrollingCollectionListSection = {
|
||||||
type: "InfiniteScrollingCollectionList";
|
type: "InfiniteScrollingCollectionList";
|
||||||
@@ -61,6 +62,7 @@ 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);
|
||||||
@@ -201,6 +203,58 @@ 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(
|
||||||
@@ -526,7 +580,7 @@ export const Home = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -538,7 +592,7 @@ export const Home = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -579,7 +633,7 @@ export const Home = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
@@ -591,7 +645,7 @@ export const Home = () => {
|
|||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -607,84 +661,106 @@ 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 */}
|
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
|
||||||
<View
|
{!showHero && settings.showHomeBackdrop && (
|
||||||
style={{
|
<View
|
||||||
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%",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</View>
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Gradient overlays for readability */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.4, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
|
||||||
paddingBottom: insets.bottom + 60,
|
paddingBottom: insets.bottom + 60,
|
||||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
|
||||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
{/* Hero Carousel - Apple TV+ style featured content */}
|
||||||
{sections.map((section, index) => {
|
{showHero && (
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
)}
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
|
||||||
? 0
|
<View
|
||||||
: 1;
|
style={{
|
||||||
|
gap: SECTION_GAP,
|
||||||
|
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
|
||||||
|
paddingTop: showHero ? SECTION_GAP : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
|
||||||
|
{sections.slice(showHero ? 1 : 0).map((section, index) => {
|
||||||
|
// Render Streamystats sections after Recently Added sections
|
||||||
|
// For default sections: place after Recently Added, before Suggested Movies (if present)
|
||||||
|
// For custom sections: place at the very end
|
||||||
|
const hasSuggestedMovies =
|
||||||
|
!settings?.streamyStatsMovieRecommendations &&
|
||||||
|
!settings?.home?.sections;
|
||||||
|
// Adjust index calculation to account for sliced array when hero is shown
|
||||||
|
const displayedSectionsLength =
|
||||||
|
sections.length - (showHero ? 1 : 0);
|
||||||
|
const streamystatsIndex =
|
||||||
|
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
|
||||||
const hasStreamystatsContent =
|
const hasStreamystatsContent =
|
||||||
settings.streamyStatsMovieRecommendations ||
|
settings.streamyStatsMovieRecommendations ||
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
settings.streamyStatsSeriesRecommendations ||
|
||||||
@@ -723,7 +799,8 @@ export const Home = () => {
|
|||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
if (section.type === "InfiniteScrollingCollectionList") {
|
||||||
const isHighPriority = section.priority === 1;
|
const isHighPriority = section.priority === 1;
|
||||||
const isFirstSection = index === 0;
|
// First section only gets preferred focus if hero is not shown
|
||||||
|
const isFirstSection = index === 0 && !showHero;
|
||||||
return (
|
return (
|
||||||
<View key={index} style={{ gap: SECTION_GAP }}>
|
<View key={index} style={{ gap: SECTION_GAP }}>
|
||||||
<InfiniteScrollingCollectionList
|
<InfiniteScrollingCollectionList
|
||||||
|
|||||||
@@ -1,631 +0,0 @@
|
|||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import type {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemDtoQueryResult,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useSegments } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
useAnimatedRef,
|
|
||||||
useScrollViewOffset,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
|
||||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
|
||||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
|
||||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { eventBus } from "@/utils/eventBus";
|
|
||||||
import { AppleTVCarousel } from "../apple-tv-carousel/AppleTVCarousel";
|
|
||||||
|
|
||||||
type InfiniteScrollingCollectionListSection = {
|
|
||||||
type: "InfiniteScrollingCollectionList";
|
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
pageSize?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSectionType = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
|
||||||
|
|
||||||
export const HomeWithCarousel = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [_loading, setLoading] = useState(false);
|
|
||||||
const { settings, refreshStreamyfinPluginSettings } = useSettings();
|
|
||||||
const headerOverlayOffset = Platform.isTV ? 0 : 60;
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollViewOffset(animatedScrollRef);
|
|
||||||
const { downloadedItems, cleanCacheDirectory } = useDownload();
|
|
||||||
const prevIsConnected = useRef<boolean | null>(false);
|
|
||||||
const {
|
|
||||||
isConnected,
|
|
||||||
serverConnected,
|
|
||||||
loading: retryLoading,
|
|
||||||
retryCheck,
|
|
||||||
} = useNetworkStatus();
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
const [scrollY, setScrollY] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isConnected && !prevIsConnected.current) {
|
|
||||||
invalidateCache();
|
|
||||||
}
|
|
||||||
prevIsConnected.current = isConnected;
|
|
||||||
}, [isConnected, invalidateCache]);
|
|
||||||
|
|
||||||
const hasDownloads = useMemo(() => {
|
|
||||||
if (Platform.isTV) return false;
|
|
||||||
return downloadedItems.length > 0;
|
|
||||||
}, [downloadedItems]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (Platform.isTV) {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className='ml-1.5'
|
|
||||||
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name='download'
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [navigation, router, hasDownloads]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cleanCacheDirectory().catch((_e) =>
|
|
||||||
console.error("Something went wrong cleaning cache directory"),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const segments = useSegments();
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
|
||||||
if ((segments as string[])[2] === "(home)")
|
|
||||||
animatedScrollRef.current?.scrollTo({
|
|
||||||
y: Platform.isTV ? -152 : -100,
|
|
||||||
animated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, [segments]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userViews = useMemo(
|
|
||||||
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
|
|
||||||
[data, settings?.hiddenLibraries],
|
|
||||||
);
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType),
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const _refetch = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await refreshStreamyfinPluginSettings();
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined,
|
|
||||||
pageSize: number = 10,
|
|
||||||
): InfiniteScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
if (!api) return [];
|
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 100, // Fetch a larger set for pagination
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
[api, user?.Id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultSections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" || c.CollectionType === "movies"
|
|
||||||
? []
|
|
||||||
: ["Movie"];
|
|
||||||
const title = t("home.recently_added_in", { libraryName: c.Name });
|
|
||||||
const queryKey: string[] = [
|
|
||||||
"home",
|
|
||||||
`recentlyAddedIn${c.CollectionType}`,
|
|
||||||
user.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to sort items by most recent activity
|
|
||||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
|
||||||
return items.sort((a, b) => {
|
|
||||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
|
||||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
|
||||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to deduplicate items by ID
|
|
||||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return items.filter((item) => {
|
|
||||||
if (!item.Id || seen.has(item.Id)) return false;
|
|
||||||
seen.add(item.Id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the first sections based on merge setting
|
|
||||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: t("home.continue_and_next_up"),
|
|
||||||
queryKey: ["home", "continueAndNextUp"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
// Fetch both in parallel
|
|
||||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
|
||||||
getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: 0,
|
|
||||||
limit: 20,
|
|
||||||
}),
|
|
||||||
getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: 0,
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const resumeItems = resumeResponse.data.Items || [];
|
|
||||||
const nextUpItems = nextUpResponse.data.Items || [];
|
|
||||||
|
|
||||||
// Combine, sort by recent activity, deduplicate
|
|
||||||
const combined = [...resumeItems, ...nextUpItems];
|
|
||||||
const sorted = sortByRecentActivity(combined);
|
|
||||||
const deduplicated = deduplicateById(sorted);
|
|
||||||
|
|
||||||
// Paginate client-side
|
|
||||||
return deduplicated.slice(pageParam, pageParam + 10);
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
title: t("home.continue_watching"),
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
fields: ["Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("home.next_up"),
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async ({ pageParam = 0 }) =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
...firstSections,
|
|
||||||
...latestMediaViews,
|
|
||||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
|
||||||
...(!settings?.streamyStatsMovieRecommendations
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: t("home.suggested_movies"),
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "InfiniteScrollingCollectionList" as const,
|
|
||||||
orientation: "vertical" as const,
|
|
||||||
pageSize: 10,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [
|
|
||||||
api,
|
|
||||||
user?.Id,
|
|
||||||
collections,
|
|
||||||
t,
|
|
||||||
createCollectionConfig,
|
|
||||||
settings?.streamyStatsMovieRecommendations,
|
|
||||||
settings.mergeNextUpAndContinueWatching,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const customSections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
|
||||||
const ss: Section[] = [];
|
|
||||||
settings.home.sections.forEach((section, index) => {
|
|
||||||
const id = section.title || `section-${index}`;
|
|
||||||
const pageSize = 10;
|
|
||||||
ss.push({
|
|
||||||
title: t(`${id}`),
|
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
|
||||||
if (section.items) {
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: section.items?.limit || pageSize,
|
|
||||||
recursive: true,
|
|
||||||
includeItemTypes: section.items?.includeItemTypes,
|
|
||||||
sortBy: section.items?.sortBy,
|
|
||||||
sortOrder: section.items?.sortOrder,
|
|
||||||
filters: section.items?.filters,
|
|
||||||
parentId: section.items?.parentId,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
if (section.nextUp) {
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount", "Genres"],
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: section.nextUp?.limit || pageSize,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
|
||||||
enableResumable: section.nextUp?.enableResumable,
|
|
||||||
enableRewatching: section.nextUp?.enableRewatching,
|
|
||||||
});
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
if (section.latest) {
|
|
||||||
// getLatestMedia doesn't support startIndex, so we fetch all and slice client-side
|
|
||||||
const allData =
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
includeItemTypes: section.latest?.includeItemTypes,
|
|
||||||
limit: section.latest?.limit || 100, // Fetch larger set
|
|
||||||
isPlayed: section.latest?.isPlayed,
|
|
||||||
groupItems: section.latest?.groupItems,
|
|
||||||
})
|
|
||||||
).data || [];
|
|
||||||
|
|
||||||
// Simulate pagination by slicing
|
|
||||||
return allData.slice(pageParam, pageParam + pageSize);
|
|
||||||
}
|
|
||||||
if (section.custom) {
|
|
||||||
const response = await api.get<BaseItemDtoQueryResult>(
|
|
||||||
section.custom.endpoint,
|
|
||||||
{
|
|
||||||
params: {
|
|
||||||
...(section.custom.query || {}),
|
|
||||||
userId: user?.Id,
|
|
||||||
startIndex: pageParam,
|
|
||||||
limit: pageSize,
|
|
||||||
},
|
|
||||||
headers: section.custom.headers || {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data.Items || [];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
type: "InfiniteScrollingCollectionList",
|
|
||||||
orientation: section?.orientation || "vertical",
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, settings?.home?.sections, t]);
|
|
||||||
|
|
||||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
|
||||||
|
|
||||||
if (!isConnected || serverConnected !== true) {
|
|
||||||
let title = "";
|
|
||||||
let subtitle = "";
|
|
||||||
|
|
||||||
if (!isConnected) {
|
|
||||||
title = t("home.no_internet");
|
|
||||||
subtitle = t("home.no_internet_message");
|
|
||||||
} else if (serverConnected === null) {
|
|
||||||
title = t("home.checking_server_connection");
|
|
||||||
subtitle = t("home.checking_server_connection_message");
|
|
||||||
} else if (!serverConnected) {
|
|
||||||
title = t("home.server_unreachable");
|
|
||||||
subtitle = t("home.server_unreachable_message");
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6 px-8'>
|
|
||||||
<Text className='text-3xl font-bold mb-2'>{title}</Text>
|
|
||||||
<Text className='text-center opacity-70'>{subtitle}</Text>
|
|
||||||
|
|
||||||
<View className='mt-4'>
|
|
||||||
{!Platform.isTV && (
|
|
||||||
<Button
|
|
||||||
color='purple'
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify='center'
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name='arrow-forward' size={20} color='white' />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("home.go_to_downloads")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color='black'
|
|
||||||
onPress={retryCheck}
|
|
||||||
justify='center'
|
|
||||||
className='mt-2'
|
|
||||||
iconRight={
|
|
||||||
retryLoading ? null : (
|
|
||||||
<Ionicons name='refresh' size={20} color='white' />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{retryLoading ? (
|
|
||||||
<ActivityIndicator size='small' color='white' />
|
|
||||||
) : (
|
|
||||||
t("home.retry")
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e1)
|
|
||||||
return (
|
|
||||||
<View className='flex flex-col items-center justify-center h-full -mt-6'>
|
|
||||||
<Text className='text-3xl font-bold mb-2'>{t("home.oops")}</Text>
|
|
||||||
<Text className='text-center opacity-70'>
|
|
||||||
{t("home.error_message")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1)
|
|
||||||
return (
|
|
||||||
<View className='justify-center items-center h-full'>
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
scrollToOverflowEnabled={true}
|
|
||||||
ref={animatedScrollRef}
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior='never'
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
bounces={false}
|
|
||||||
overScrollMode='never'
|
|
||||||
style={{ marginTop: -headerOverlayOffset }}
|
|
||||||
contentContainerStyle={{ paddingTop: headerOverlayOffset }}
|
|
||||||
onScroll={(event) => {
|
|
||||||
setScrollY(event.nativeEvent.contentOffset.y);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AppleTVCarousel initialIndex={0} scrollOffset={scrollOffset} />
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
paddingTop: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className='flex flex-col space-y-4'>
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
|
||||||
? 0
|
|
||||||
: 1;
|
|
||||||
const hasStreamystatsContent =
|
|
||||||
settings.streamyStatsMovieRecommendations ||
|
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
|
||||||
settings.streamyStatsPromotedWatchlists;
|
|
||||||
const streamystatsSections =
|
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
|
||||||
<>
|
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
|
||||||
<StreamystatsRecommendations
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
|
||||||
)}
|
|
||||||
type='Movie'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
|
||||||
<StreamystatsRecommendations
|
|
||||||
title={t(
|
|
||||||
"home.settings.plugins.streamystats.recommended_series",
|
|
||||||
)}
|
|
||||||
type='Series'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
|
||||||
<StreamystatsPromotedWatchlists />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
|
||||||
<InfiniteScrollingCollectionList
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
hideIfEmpty
|
|
||||||
pageSize={section.pageSize}
|
|
||||||
/>
|
|
||||||
{streamystatsSections}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<View key={index} className='flex flex-col space-y-4'>
|
|
||||||
<MediaListSection
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
scrollY={scrollY}
|
|
||||||
enableLazyLoading={true}
|
|
||||||
/>
|
|
||||||
{streamystatsSections}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View className='h-24' />
|
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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 = 16;
|
const ITEM_GAP = 24;
|
||||||
// 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,22 +48,27 @@ 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<{ 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" }}>
|
||||||
{item.Type === "Episode" ? (
|
{item.Type === "Episode" ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -77,13 +82,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -103,7 +108,8 @@ const TVSeeAllCard: React.FC<{
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
|
typography: Typography;
|
||||||
|
}> = ({ 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;
|
||||||
@@ -137,7 +143,7 @@ const TVSeeAllCard: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
}}
|
}}
|
||||||
@@ -165,6 +171,7 @@ 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();
|
||||||
@@ -211,7 +218,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -343,7 +350,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{renderPoster()}
|
{renderPoster()}
|
||||||
</TVFocusablePoster>
|
</TVFocusablePoster>
|
||||||
<TVItemCardText item={item} />
|
<TVItemCardText item={item} typography={typography} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -354,6 +361,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
handleItemPress,
|
handleItemPress,
|
||||||
handleItemFocus,
|
handleItemFocus,
|
||||||
handleItemBlur,
|
handleItemBlur,
|
||||||
|
typography,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -365,11 +373,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -379,7 +388,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#737373",
|
color: "#737373",
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -420,7 +429,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
color: "#262626",
|
color: "#262626",
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -477,6 +486,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onFocus={handleSeeAllFocus}
|
onFocus={handleSeeAllFocus}
|
||||||
onBlur={handleItemBlur}
|
onBlur={handleItemBlur}
|
||||||
|
typography={typography}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ 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,7 +80,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,7 +214,6 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
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";
|
||||||
@@ -26,18 +26,23 @@ import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
|||||||
const ITEM_GAP = 16;
|
const ITEM_GAP = 16;
|
||||||
const SCALE_PADDING = 20;
|
const SCALE_PADDING = 20;
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
type Typography = ReturnType<typeof useScaledTVTypography>;
|
||||||
|
|
||||||
|
const TVItemCardText: React.FC<{
|
||||||
|
item: BaseItemDto;
|
||||||
|
typography: Typography;
|
||||||
|
}> = ({ item, typography }) => {
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
<View style={{ marginTop: 12, flexDirection: "column" }}>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -60,6 +65,7 @@ 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();
|
||||||
@@ -108,8 +114,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: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,11 +148,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} />
|
<TVItemCardText item={item} typography={typography} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress, onItemFocus],
|
[handleItemPress, onItemFocus, typography],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isLoading && (!items || items.length === 0)) return null;
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
@@ -155,11 +161,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
<View style={{ overflow: "visible" }} {...props}>
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{watchlist.name}
|
{watchlist.name}
|
||||||
@@ -278,8 +285,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +135,6 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
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";
|
||||||
@@ -26,6 +26,8 @@ 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";
|
||||||
@@ -34,18 +36,21 @@ interface Props extends ViewProps {
|
|||||||
onItemFocus?: (item: BaseItemDto) => void;
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
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: TVTypography.callout, color: "#FFFFFF" }}
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -64,6 +69,7 @@ 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();
|
||||||
@@ -133,8 +139,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
Boolean(api?.accessToken) &&
|
Boolean(api?.accessToken) &&
|
||||||
Boolean(jellyfinServerId) &&
|
Boolean(jellyfinServerId) &&
|
||||||
Boolean(user?.Id),
|
Boolean(user?.Id),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,8 +172,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: 5 * 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
refetchOnMount: false,
|
refetchInterval: 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,11 +209,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} />
|
<TVItemCardText item={item} typography={typography} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress, onItemFocus],
|
[handleItemPress, onItemFocus, typography],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!streamyStatsEnabled) return null;
|
if (!streamyStatsEnabled) return null;
|
||||||
@@ -218,11 +224,12 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
<View style={{ overflow: "visible" }} {...props}>
|
<View style={{ overflow: "visible" }} {...props}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
624
components/home/TVHeroCarousel.tsx
Normal file
624
components/home/TVHeroCarousel.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
FlatList,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ProgressBar } from "@/components/common/ProgressBar";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
||||||
|
const HERO_HEIGHT = SCREEN_HEIGHT * 0.62;
|
||||||
|
const CARD_WIDTH = 280;
|
||||||
|
const CARD_GAP = 24;
|
||||||
|
const CARD_PADDING = 60;
|
||||||
|
|
||||||
|
interface TVHeroCarouselProps {
|
||||||
|
items: BaseItemDto[];
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCardProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
isFirst: boolean;
|
||||||
|
onFocus: (item: BaseItemDto) => void;
|
||||||
|
onPress: (item: BaseItemDto) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeroCard: React.FC<HeroCardProps> = React.memo(
|
||||||
|
({ item, isFirst, onFocus, onPress }) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// Check if glass effect is available (tvOS 26+)
|
||||||
|
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
|
||||||
|
|
||||||
|
const posterUrl = useMemo(() => {
|
||||||
|
if (!api) return null;
|
||||||
|
|
||||||
|
// For episodes, always use series thumb
|
||||||
|
if (item.Type === "Episode") {
|
||||||
|
if (item.ParentThumbImageTag) {
|
||||||
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
|
}
|
||||||
|
if (item.SeriesId) {
|
||||||
|
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-episodes, use item's own thumb/primary
|
||||||
|
if (item.ImageTags?.Thumb) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
|
||||||
|
}
|
||||||
|
if (item.ImageTags?.Primary) {
|
||||||
|
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [api, item]);
|
||||||
|
|
||||||
|
const progress = item.UserData?.PlayedPercentage || 0;
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(value: number) =>
|
||||||
|
Animated.timing(scale, {
|
||||||
|
toValue: value,
|
||||||
|
duration: 150,
|
||||||
|
easing: Easing.out(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start(),
|
||||||
|
[scale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
animateTo(1.1);
|
||||||
|
onFocus(item);
|
||||||
|
}, [animateTo, onFocus, item]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setFocused(false);
|
||||||
|
animateTo(1);
|
||||||
|
}, [animateTo]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onPress(item);
|
||||||
|
}, [onPress, item]);
|
||||||
|
|
||||||
|
// Use glass poster for tvOS 26+
|
||||||
|
if (useGlass) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: CARD_GAP }}
|
||||||
|
>
|
||||||
|
<GlassPosterView
|
||||||
|
imageUrl={posterUrl}
|
||||||
|
aspectRatio={16 / 9}
|
||||||
|
cornerRadius={16}
|
||||||
|
progress={progress}
|
||||||
|
showWatchedIndicator={false}
|
||||||
|
isFocused={focused}
|
||||||
|
width={CARD_WIDTH}
|
||||||
|
style={{ width: CARD_WIDTH }}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for non-tvOS or older tvOS
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={isFirst}
|
||||||
|
style={{ marginRight: CARD_GAP }}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
transform: [{ scale }],
|
||||||
|
shadowColor: "#FFFFFF",
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: focused ? 0.6 : 0,
|
||||||
|
shadowRadius: focused ? 20 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{posterUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: posterUrl }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "rgba(255,255,255,0.1)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='film-outline'
|
||||||
|
size={48}
|
||||||
|
color='rgba(255,255,255,0.3)'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ProgressBar item={item} />
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce delay to prevent rapid backdrop changes when navigating fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
|
||||||
|
items,
|
||||||
|
onItemFocus,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Active item for featured display (debounced)
|
||||||
|
const [activeItem, setActiveItem] = useState<BaseItemDto | null>(
|
||||||
|
items[0] || null,
|
||||||
|
);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Cleanup debounce timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Crossfade animation state
|
||||||
|
const [activeLayer, setActiveLayer] = useState<0 | 1>(0);
|
||||||
|
const [layer0Url, setLayer0Url] = useState<string | null>(null);
|
||||||
|
const [layer1Url, setLayer1Url] = useState<string | null>(null);
|
||||||
|
const layer0Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
const layer1Opacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Get backdrop URL for active item
|
||||||
|
const backdropUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item: activeItem,
|
||||||
|
quality: 90,
|
||||||
|
width: 1920,
|
||||||
|
});
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Get logo URL for active item
|
||||||
|
const logoUrl = useMemo(() => {
|
||||||
|
if (!activeItem) return null;
|
||||||
|
return getLogoImageUrlById({ api, item: activeItem });
|
||||||
|
}, [api, activeItem]);
|
||||||
|
|
||||||
|
// Crossfade effect for backdrop
|
||||||
|
useEffect(() => {
|
||||||
|
if (!backdropUrl) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
const performCrossfade = async () => {
|
||||||
|
try {
|
||||||
|
await Image.prefetch(backdropUrl);
|
||||||
|
} catch {
|
||||||
|
// Continue even if prefetch fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const incomingLayer = activeLayer === 0 ? 1 : 0;
|
||||||
|
const incomingOpacity =
|
||||||
|
incomingLayer === 0 ? layer0Opacity : layer1Opacity;
|
||||||
|
const outgoingOpacity =
|
||||||
|
incomingLayer === 0 ? layer1Opacity : layer0Opacity;
|
||||||
|
|
||||||
|
if (incomingLayer === 0) {
|
||||||
|
setLayer0Url(backdropUrl);
|
||||||
|
} else {
|
||||||
|
setLayer1Url(backdropUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(incomingOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(outgoingOpacity, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.inOut(Easing.quad),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start(() => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
setActiveLayer(incomingLayer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
performCrossfade();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
}, [backdropUrl]);
|
||||||
|
|
||||||
|
// Handle card focus with debounce
|
||||||
|
const handleCardFocus = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
// Clear any pending debounce timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
// Set new timer to update active item after debounce delay
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
setActiveItem(item);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
}, BACKDROP_DEBOUNCE_MS);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle card press - navigate to item
|
||||||
|
const handleCardPress = useCallback(
|
||||||
|
(item: BaseItemDto) => {
|
||||||
|
const navigation = getItemNavigation(item, "(home)");
|
||||||
|
router.push(navigation as any);
|
||||||
|
},
|
||||||
|
[router],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get metadata for active item
|
||||||
|
const year = activeItem?.ProductionYear;
|
||||||
|
const duration = activeItem?.RunTimeTicks
|
||||||
|
? runtimeTicksToMinutes(activeItem.RunTimeTicks)
|
||||||
|
: null;
|
||||||
|
const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0;
|
||||||
|
const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0;
|
||||||
|
|
||||||
|
// Get display title
|
||||||
|
const displayTitle = useMemo(() => {
|
||||||
|
if (!activeItem) return "";
|
||||||
|
if (activeItem.Type === "Episode") {
|
||||||
|
return activeItem.SeriesName || activeItem.Name || "";
|
||||||
|
}
|
||||||
|
return activeItem.Name || "";
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Get subtitle for episodes
|
||||||
|
const episodeSubtitle = useMemo(() => {
|
||||||
|
if (!activeItem || activeItem.Type !== "Episode") return null;
|
||||||
|
return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} · ${activeItem.Name}`;
|
||||||
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// Memoize hero items to prevent re-renders
|
||||||
|
const heroItems = useMemo(() => items.slice(0, 8), [items]);
|
||||||
|
|
||||||
|
// Memoize renderItem for FlatList
|
||||||
|
const renderHeroCard = useCallback(
|
||||||
|
({ item, index }: { item: BaseItemDto; index: number }) => (
|
||||||
|
<HeroCard
|
||||||
|
item={item}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onFocus={handleCardFocus}
|
||||||
|
onPress={handleCardPress}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[handleCardFocus, handleCardPress],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize keyExtractor
|
||||||
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ height: HERO_HEIGHT, width: "100%" }}>
|
||||||
|
{/* Backdrop layers with crossfade */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Layer 0 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer0Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer0Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer0Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
{/* Layer 1 */}
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
opacity: layer1Opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layer1Url && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: layer1Url }}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
contentFit='cover'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Gradient overlays */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["transparent", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.95)"]}
|
||||||
|
locations={[0, 0.5, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
height: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.4)", "transparent"]}
|
||||||
|
locations={[0, 1]}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: "40%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Horizontal gradient for left side text contrast */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={["rgba(0,0,0,0.9)", "rgba(0,0,0,0.6)", "transparent"]}
|
||||||
|
locations={[0, 0.5, 0.85]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: "70%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content overlay */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: insets.left + CARD_PADDING,
|
||||||
|
right: insets.right + CARD_PADDING,
|
||||||
|
bottom: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo or Title */}
|
||||||
|
{logoUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logoUrl }}
|
||||||
|
style={{
|
||||||
|
height: 100,
|
||||||
|
width: SCREEN_WIDTH * 0.35,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
contentFit='contain'
|
||||||
|
contentPosition='left'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.display,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#FFFFFF",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Episode subtitle */}
|
||||||
|
{episodeSubtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.9)",
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{episodeSubtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{activeItem?.Overview && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
marginBottom: 16,
|
||||||
|
maxWidth: SCREEN_WIDTH * 0.5,
|
||||||
|
lineHeight: typography.body * 1.4,
|
||||||
|
}}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{activeItem.Overview}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges */}
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{activeItem?.OfficialRating && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255,255,255,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeItem.OfficialRating}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasProgress && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 60,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: "rgba(255,255,255,0.3)",
|
||||||
|
borderRadius: 2,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${playedPercent}%`,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "rgba(255,255,255,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(playedPercent)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Thumbnail carousel */}
|
||||||
|
<FlatList
|
||||||
|
horizontal
|
||||||
|
data={heroItems}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={{ overflow: "visible" }}
|
||||||
|
contentContainerStyle={{ paddingVertical: 12 }}
|
||||||
|
renderItem={renderHeroCard}
|
||||||
|
removeClippedSubviews={false}
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={3}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
import { 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import useRouter from "@/hooks/useAppRouter";
|
import useRouter from "@/hooks/useAppRouter";
|
||||||
import {
|
import {
|
||||||
type DiscoverEndpoint,
|
type DiscoverEndpoint,
|
||||||
@@ -33,6 +33,7 @@ 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 } =
|
||||||
@@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -142,7 +143,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
|||||||
{year && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -164,6 +165,7 @@ 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();
|
||||||
|
|
||||||
@@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
|||||||
<View style={{ marginBottom: 24 }}>
|
<View style={{ marginBottom: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +68,7 @@ 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 });
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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",
|
||||||
@@ -158,6 +159,7 @@ 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();
|
||||||
@@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.display,
|
fontSize: typography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -566,7 +568,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
{/* Year */}
|
{/* Year */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "rgba(255,255,255,0.7)",
|
color: "rgba(255,255,255,0.7)",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
@@ -601,7 +603,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: "#E5E7EB",
|
color: "#E5E7EB",
|
||||||
lineHeight: 32,
|
lineHeight: 32,
|
||||||
}}
|
}}
|
||||||
@@ -636,7 +638,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -663,7 +665,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -698,7 +700,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -732,7 +734,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -757,7 +759,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: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
}}
|
}}
|
||||||
@@ -776,7 +778,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -794,7 +796,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -813,7 +815,7 @@ export const TVJellyseerrPage: React.FC = () => {
|
|||||||
<View style={{ marginTop: 24 }}>
|
<View style={{ marginTop: 24 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
import type {
|
import type {
|
||||||
QualityProfile,
|
QualityProfile,
|
||||||
@@ -51,6 +51,7 @@ 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();
|
||||||
|
|
||||||
@@ -389,7 +390,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -399,7 +400,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
@@ -473,7 +474,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface TVRequestOptionRowProps {
|
interface TVRequestOptionRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,6 +20,7 @@ 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,
|
||||||
@@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
|
|||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
interface ToggleItem {
|
interface ToggleItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,6 +21,7 @@ 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,
|
||||||
@@ -57,7 +58,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: item.selected || focused ? "600" : "400",
|
fontWeight: item.selected || focused ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
@@ -82,13 +83,14 @@ 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: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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";
|
||||||
@@ -55,6 +56,7 @@ 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;
|
||||||
@@ -190,7 +192,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 32,
|
fontSize: typography.heading,
|
||||||
fontWeight: "700",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
textShadowColor: "rgba(0,0,0,0.8)",
|
textShadowColor: "rgba(0,0,0,0.8)",
|
||||||
@@ -203,7 +205,7 @@ const TVLibraryRow: React.FC<{
|
|||||||
{library.itemCount !== undefined && (
|
{library.itemCount !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 18,
|
fontSize: typography.body,
|
||||||
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)",
|
||||||
@@ -237,6 +239,7 @@ 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],
|
||||||
@@ -360,7 +363,7 @@ export const TVLibraries: React.FC = () => {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: 20, color: "#737373" }}>
|
<Text style={{ fontSize: typography.body, color: "#737373" }}>
|
||||||
{t("library.no_libraries_found")}
|
{t("library.no_libraries_found")}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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";
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
@@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: typography.body,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -160,7 +162,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
|
|||||||
{itemsCount !== undefined && (
|
{itemsCount !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 14,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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;
|
||||||
@@ -59,8 +58,8 @@ export const TVInput: React.FC<TVInputProps> = ({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
borderRadius: size(20),
|
borderRadius: 10,
|
||||||
borderWidth: size(6),
|
borderWidth: 3,
|
||||||
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
borderColor: isFocused ? "#FFFFFF" : "#333333",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -70,8 +69,8 @@ export const TVInput: React.FC<TVInputProps> = ({
|
|||||||
allowFontScaling={false}
|
allowFontScaling={false}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
height: size(200),
|
height: 68,
|
||||||
fontSize: fontSize(12),
|
fontSize: 24,
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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";
|
||||||
@@ -67,7 +66,7 @@ const TVBackButton: React.FC<{
|
|||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
animateFocus(false);
|
animateFocus(false);
|
||||||
}}
|
}}
|
||||||
style={{ alignSelf: "flex-start", marginBottom: size(40) }}
|
style={{ alignSelf: "flex-start", marginBottom: 40 }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
focusable={!disabled}
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
@@ -76,25 +75,26 @@ const TVBackButton: React.FC<{
|
|||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: size(12),
|
paddingVertical: 8,
|
||||||
borderRadius: size(8),
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 8,
|
||||||
backgroundColor: isFocused
|
backgroundColor: isFocused
|
||||||
? "rgba(168, 85, 247, 0.2)"
|
? "rgba(168, 85, 247, 0.2)"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
borderWidth: size(2),
|
borderWidth: 2,
|
||||||
borderColor: isFocused ? Colors.primary : "transparent",
|
borderColor: isFocused ? Colors.primary : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-back'
|
name='chevron-back'
|
||||||
size={size(28)}
|
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: fontSize(14),
|
fontSize: 20,
|
||||||
marginLeft: size(4),
|
marginLeft: 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: size(20),
|
paddingVertical: 60,
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
@@ -471,7 +471,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
paddingHorizontal: size(40),
|
paddingHorizontal: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
@@ -484,10 +484,10 @@ export const TVLogin: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(12),
|
fontSize: 48,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: size(8),
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{serverName ? (
|
{serverName ? (
|
||||||
@@ -501,18 +501,16 @@ export const TVLogin: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(14),
|
fontSize: 18,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: size(40),
|
marginBottom: 40,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{api.basePath}
|
{api.basePath}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Username Input - extra padding for focus scale */}
|
{/* Username Input - extra padding for focus scale */}
|
||||||
<View
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
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}
|
||||||
@@ -529,9 +527,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Password Input */}
|
{/* Password Input */}
|
||||||
<View
|
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
|
||||||
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}
|
||||||
@@ -547,9 +543,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Save Account Toggle */}
|
{/* Save Account Toggle */}
|
||||||
<View
|
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
|
||||||
style={{ marginBottom: size(40), paddingHorizontal: size(8) }}
|
|
||||||
>
|
|
||||||
<TVSaveAccountToggle
|
<TVSaveAccountToggle
|
||||||
value={saveAccount}
|
value={saveAccount}
|
||||||
onValueChange={setSaveAccount}
|
onValueChange={setSaveAccount}
|
||||||
@@ -559,7 +553,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Login Button */}
|
{/* Login Button */}
|
||||||
<View style={{ marginBottom: size(16) }}>
|
<View style={{ marginBottom: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -585,11 +579,9 @@ 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",
|
||||||
justifyContent: "flex-start",
|
paddingVertical: 60,
|
||||||
paddingTop: size(40),
|
|
||||||
paddingBottom: size(40),
|
|
||||||
}}
|
}}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
@@ -597,14 +589,14 @@ export const TVLogin: React.FC = () => {
|
|||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 800,
|
maxWidth: 800,
|
||||||
paddingHorizontal: size(60),
|
paddingHorizontal: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<View style={{ alignItems: "center", marginBottom: size(5) }}>
|
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||||
<Image
|
<Image
|
||||||
source={require("@/assets/images/icon-tvos.png")}
|
source={require("@/assets/images/icon-tvos.png")}
|
||||||
style={{ width: size(400), height: size(400) }}
|
style={{ width: 150, height: 150 }}
|
||||||
contentFit='contain'
|
contentFit='contain'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -612,30 +604,28 @@ export const TVLogin: React.FC = () => {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(24),
|
fontSize: 48,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: size(8),
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Streamyfin
|
Streamyfin
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(10),
|
fontSize: 20,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
marginBottom: size(40),
|
marginBottom: 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
|
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
|
||||||
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}
|
||||||
@@ -650,9 +640,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Connect Button */}
|
{/* Connect Button */}
|
||||||
<View
|
<View style={{ marginBottom: 24 }}>
|
||||||
style={{ marginBottom: size(12), paddingHorizontal: size(270) }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={() => handleConnect(serverURL)}
|
onPress={() => handleConnect(serverURL)}
|
||||||
loading={loadingServerCheck}
|
loading={loadingServerCheck}
|
||||||
@@ -663,7 +651,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Previous Servers */}
|
{/* Previous Servers */}
|
||||||
<View style={{ paddingHorizontal: size(300) }}>
|
<View style={{ paddingHorizontal: 8 }}>
|
||||||
<TVPreviousServersList
|
<TVPreviousServersList
|
||||||
onServerSelect={(s) => handleConnect(s.address)}
|
onServerSelect={(s) => handleConnect(s.address)}
|
||||||
onQuickLogin={handleQuickLoginWithSavedCredential}
|
onQuickLogin={handleQuickLoginWithSavedCredential}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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 {
|
||||||
@@ -62,8 +61,8 @@ const TVServerActionCard: React.FC<{
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={{
|
style={{
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
width: width(25),
|
width: 180,
|
||||||
height: height(15),
|
height: 90,
|
||||||
backgroundColor: focused
|
backgroundColor: focused
|
||||||
? isDestructive
|
? isDestructive
|
||||||
? "#ef4444"
|
? "#ef4444"
|
||||||
@@ -71,16 +70,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: size(20),
|
borderRadius: 14,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: size(12),
|
paddingHorizontal: 12,
|
||||||
gap: size(8),
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={icon}
|
name={icon}
|
||||||
size={size(56)}
|
size={28}
|
||||||
color={
|
color={
|
||||||
focused
|
focused
|
||||||
? isDestructive
|
? isDestructive
|
||||||
@@ -93,7 +92,7 @@ const TVServerActionCard: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(12),
|
fontSize: 16,
|
||||||
color: focused
|
color: focused
|
||||||
? isDestructive
|
? isDestructive
|
||||||
? "#fff"
|
? "#fff"
|
||||||
@@ -143,26 +142,26 @@ const TVServerActionSheet: React.FC<{
|
|||||||
intensity={80}
|
intensity={80}
|
||||||
tint='dark'
|
tint='dark'
|
||||||
style={{
|
style={{
|
||||||
borderTopLeftRadius: size(24),
|
borderTopLeftRadius: 24,
|
||||||
borderTopRightRadius: size(24),
|
borderTopRightRadius: 24,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingTop: size(24),
|
paddingTop: 24,
|
||||||
paddingBottom: size(50),
|
paddingBottom: 50,
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(12),
|
fontSize: 18,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
color: "rgba(255,255,255,0.6)",
|
color: "rgba(255,255,255,0.6)",
|
||||||
marginBottom: size(12),
|
marginBottom: 8,
|
||||||
paddingHorizontal: size(48),
|
paddingHorizontal: 48,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
}}
|
}}
|
||||||
@@ -176,9 +175,9 @@ const TVServerActionSheet: React.FC<{
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={{ overflow: "visible" }}
|
style={{ overflow: "visible" }}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingHorizontal: size(48),
|
paddingHorizontal: 48,
|
||||||
paddingVertical: size(10),
|
paddingVertical: 10,
|
||||||
gap: size(12),
|
gap: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TVServerActionCard
|
<TVServerActionCard
|
||||||
@@ -399,19 +398,19 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
if (!previousServers.length) return null;
|
if (!previousServers.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginTop: size(100) }}>
|
<View style={{ marginTop: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(12),
|
fontSize: 24,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: size(20),
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("server.previous_servers")}
|
{t("server.previous_servers")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ gap: size(12) }}>
|
<View style={{ gap: 12 }}>
|
||||||
{previousServers.map((server) => (
|
{previousServers.map((server) => (
|
||||||
<TVServerCard
|
<TVServerCard
|
||||||
key={server.address}
|
key={server.address}
|
||||||
@@ -438,39 +437,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: size(80),
|
padding: 80,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "#1a1a1a",
|
backgroundColor: "#1a1a1a",
|
||||||
borderRadius: size(24),
|
borderRadius: 24,
|
||||||
padding: size(40),
|
padding: 40,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: width(70),
|
maxWidth: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(32),
|
fontSize: 32,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: size(8),
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("server.select_account")}
|
{t("server.select_account")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(18),
|
fontSize: 18,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: size(32),
|
marginBottom: 32,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedServer?.name || selectedServer?.address}
|
{selectedServer?.name || selectedServer?.address}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={{ gap: size(12), marginBottom: size(24) }}>
|
<View style={{ gap: 12, marginBottom: 24 }}>
|
||||||
{selectedServer?.accounts.map((account, index) => (
|
{selectedServer?.accounts.map((account, index) => (
|
||||||
<TVAccountCard
|
<TVAccountCard
|
||||||
key={account.userId}
|
key={account.userId}
|
||||||
@@ -485,7 +484,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ gap: size(12) }}>
|
<View style={{ gap: 12 }}>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setShowAccountsModal(false);
|
setShowAccountsModal(false);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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";
|
||||||
|
|
||||||
@@ -87,11 +86,11 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
|
||||||
borderWidth: size(6),
|
borderWidth: 2,
|
||||||
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
borderColor: isFocused ? "#FFFFFF" : "transparent",
|
||||||
borderRadius: size(50),
|
borderRadius: 16,
|
||||||
paddingHorizontal: size(50),
|
paddingHorizontal: 24,
|
||||||
paddingVertical: size(40),
|
paddingVertical: 20,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
@@ -100,7 +99,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(12),
|
fontSize: 22,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
@@ -111,9 +110,9 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: fontSize(10),
|
fontSize: 16,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: size(12),
|
marginTop: 4,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -122,27 +121,27 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ marginLeft: size(16) }}>
|
<View style={{ marginLeft: 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={size(60)}
|
size={20}
|
||||||
color={Colors.primary}
|
color={Colors.primary}
|
||||||
style={{ marginRight: size(16) }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-forward'
|
name='chevron-forward'
|
||||||
size={size(48)}
|
size={24}
|
||||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='chevron-forward'
|
name='chevron-forward'
|
||||||
size={size(48)}
|
size={24}
|
||||||
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
color={isFocused ? "#FFFFFF" : "#6B7280"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ 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 = 210;
|
export const TV_POSTER_WIDTH = 260;
|
||||||
|
|
||||||
type MoviePosterProps = {
|
type MoviePosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -24,17 +28,37 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
|
|||||||
return getPrimaryImageUrl({
|
return getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
width: 420, // 2x for quality on large screens
|
width: 520, // 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,10 +3,14 @@ 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 = 210;
|
export const TV_POSTER_WIDTH = 260;
|
||||||
|
|
||||||
type SeriesPosterProps = {
|
type SeriesPosterProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -18,12 +22,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=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`;
|
||||||
}
|
}
|
||||||
return getPrimaryImageUrl({
|
return getPrimaryImageUrl({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
width: 420, // 2x for quality on large screens
|
width: 520, // 2x for quality on large screens
|
||||||
});
|
});
|
||||||
}, [api, item]);
|
}, [api, item]);
|
||||||
|
|
||||||
@@ -32,6 +36,25 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +27,7 @@ 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 });
|
||||||
@@ -113,7 +114,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -125,7 +126,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
|||||||
{year && (
|
{year && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
@@ -147,6 +148,7 @@ 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 });
|
||||||
@@ -202,7 +204,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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,
|
||||||
@@ -230,13 +232,14 @@ 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: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -281,13 +284,14 @@ 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: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -332,13 +336,14 @@ 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: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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;
|
||||||
@@ -14,6 +15,7 @@ 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 });
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: focused ? "600" : "400",
|
fontWeight: focused ? "600" : "400",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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";
|
||||||
@@ -26,6 +27,7 @@ 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" }}>
|
||||||
@@ -71,7 +73,7 @@ const TVLoadingSkeleton: React.FC = () => {
|
|||||||
color: "#262626",
|
color: "#262626",
|
||||||
backgroundColor: "#262626",
|
backgroundColor: "#262626",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
fontSize: 16,
|
fontSize: typography.callout,
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
@@ -149,6 +151,7 @@ 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);
|
||||||
@@ -222,7 +225,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
|
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING + 200 }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search.search")}
|
placeholder={t("search.search")}
|
||||||
value={search}
|
value={search}
|
||||||
@@ -307,7 +310,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 24,
|
fontSize: typography.heading,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
@@ -315,7 +318,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
|||||||
>
|
>
|
||||||
{t("search.no_results_found_for")}
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.body,
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
"{debouncedSearch}"
|
"{debouncedSearch}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11,22 +11,31 @@ 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 numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
|
||||||
{" - "}
|
{" - "}
|
||||||
@@ -36,53 +45,92 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
) : item.Type === "MusicArtist" ? (
|
) : item.Type === "MusicArtist" ? (
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
) : item.Type === "MusicAlbum" ? (
|
) : item.Type === "MusicAlbum" ? (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
style={{
|
||||||
|
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 numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
|
style={{
|
||||||
|
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 numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.ChildCount} tracks
|
{item.ChildCount} tracks
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : item.Type === "Person" ? (
|
) : item.Type === "Person" ? (
|
||||||
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{item.ProductionYear}
|
{item.ProductionYear}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -111,6 +159,7 @@ 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);
|
||||||
@@ -311,11 +360,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 22,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 20,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
|
letterSpacing: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -2,6 +2,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";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ 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 });
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: 16,
|
fontSize: typography.callout,
|
||||||
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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ 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(() => {
|
||||||
@@ -67,7 +68,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
}, [episode.ParentIndexNumber, episode.IndexNumber]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ width: TV_EPISODE_WIDTH }}>
|
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}>
|
||||||
<TVFocusablePoster
|
<TVFocusablePoster
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
hasTVPreferredFocus={hasTVPreferredFocus}
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
||||||
@@ -112,8 +113,8 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
{episodeLabel && (
|
{episodeLabel && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#FFFFFF",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -122,14 +123,10 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{duration && (
|
{duration && (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
|
||||||
style={{ color: "#6B7280", fontSize: TVTypography.callout }}
|
|
||||||
>
|
|
||||||
•
|
•
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
|
||||||
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
|
|
||||||
>
|
|
||||||
{duration}
|
{duration}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -138,7 +135,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +19,7 @@ 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(() => {
|
||||||
@@ -58,7 +59,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
|||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.display,
|
fontSize: typography.display,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -80,7 +81,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{yearString && (
|
{yearString && (
|
||||||
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
<Text style={{ color: "white", fontSize: typography.body }}>
|
||||||
{yearString}
|
{yearString}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -123,7 +124,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +142,7 @@ 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;
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ const TVSeasonButton: React.FC<{
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
color: focused ? "#000" : "#FFFFFF",
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
}}
|
}}
|
||||||
@@ -213,6 +214,7 @@ 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();
|
||||||
@@ -293,6 +295,7 @@ 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +348,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
},
|
},
|
||||||
staleTime: isOffline ? Infinity : 0,
|
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||||
|
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,
|
||||||
@@ -565,7 +569,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#000000",
|
color: "#000000",
|
||||||
}}
|
}}
|
||||||
@@ -589,10 +593,10 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
<View style={{ marginTop: 40, overflow: "visible" }}>
|
<View style={{ marginTop: 40, overflow: "visible" }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 24,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -644,7 +648,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
|||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
color: "#737373",
|
color: "#737373",
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
marginLeft: SCALE_PADDING,
|
marginLeft: SCALE_PADDING,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,14 +42,6 @@ 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,14 +158,6 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVActorCardProps {
|
export interface TVActorCardProps {
|
||||||
@@ -19,6 +19,7 @@ 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 });
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
|||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.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",
|
||||||
@@ -98,7 +99,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
|
|||||||
{person.Role && (
|
{person.Role && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVCancelButtonProps {
|
export interface TVCancelButtonProps {
|
||||||
@@ -16,6 +16,7 @@ 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 });
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
|
|||||||
/>
|
/>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVCastCrewTextProps {
|
export interface TVCastCrewTextProps {
|
||||||
director?: BaseItemPerson | null;
|
director?: BaseItemPerson | null;
|
||||||
@@ -14,6 +14,7 @@ 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)) {
|
||||||
@@ -24,7 +25,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -37,7 +38,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@@ -46,7 +47,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
>
|
>
|
||||||
{t("item_card.director")}
|
{t("item_card.director")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||||
{director.Name}
|
{director.Name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -55,7 +56,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
@@ -64,7 +65,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
|
|||||||
>
|
>
|
||||||
{t("item_card.cast")}
|
{t("item_card.cast")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { TVActorCard } from "./TVActorCard";
|
import { TVActorCard } from "./TVActorCard";
|
||||||
|
|
||||||
export interface TVCastSectionProps {
|
export interface TVCastSectionProps {
|
||||||
@@ -24,6 +24,7 @@ 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) {
|
||||||
@@ -34,7 +35,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 40 }}>
|
<View style={{ marginBottom: 40 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ 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;
|
||||||
@@ -23,6 +25,7 @@ export interface TVControlButtonProps {
|
|||||||
|
|
||||||
export const TVControlButton: FC<TVControlButtonProps> = ({
|
export const TVControlButton: FC<TVControlButtonProps> = ({
|
||||||
icon,
|
icon,
|
||||||
|
text,
|
||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
onPressOut,
|
onPressOut,
|
||||||
@@ -63,7 +66,11 @@ export const TVControlButton: FC<TVControlButtonProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name={icon} size={size} color='#fff' />
|
{text ? (
|
||||||
|
<Text style={[styles.text, { fontSize: size * 0.4 }]}>{text}</Text>
|
||||||
|
) : (
|
||||||
|
<Ionicons name={icon!} size={size} color='#fff' />
|
||||||
|
)}
|
||||||
</RNAnimated.View>
|
</RNAnimated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -78,4 +85,9 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
text: {
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: "600",
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
79
components/tv/TVFilterButton.tsx
Normal file
79
components/tv/TVFilterButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Animated, Pressable, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
|
export interface TVFilterButtonProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onPress: () => void;
|
||||||
|
hasTVPreferredFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasActiveFilter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVFilterButton: React.FC<TVFilterButtonProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onPress,
|
||||||
|
hasTVPreferredFocus = false,
|
||||||
|
disabled = false,
|
||||||
|
hasActiveFilter = false,
|
||||||
|
}) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||||
|
useTVFocusAnimation({ scaleAmount: 1.04, duration: 120 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
focusable={!disabled}
|
||||||
|
>
|
||||||
|
<Animated.View style={animatedStyle}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: focused
|
||||||
|
? "#fff"
|
||||||
|
: hasActiveFilter
|
||||||
|
? "rgba(255, 255, 255, 0.25)"
|
||||||
|
: "rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: hasActiveFilter && !focused ? 1 : 0,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#444" : "#bbb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout,
|
||||||
|
color: focused ? "#000" : "#FFFFFF",
|
||||||
|
fontWeight: "500",
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -70,8 +70,8 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
|
|||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
shadowColor,
|
shadowColor,
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: focused ? 0.6 : 0,
|
shadowOpacity: focused ? 0.3 : 0,
|
||||||
shadowRadius: focused ? 20 : 0,
|
shadowRadius: focused ? 12 : 0,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
|
|||||||
33
components/tv/TVItemCardText.tsx
Normal file
33
components/tv/TVItemCardText.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import React from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
|
export interface TVItemCardTextProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TVItemCardText: React.FC<TVItemCardTextProps> = ({ item }) => {
|
||||||
|
const typography = useScaledTVTypography();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginTop: 12 }}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
|
||||||
|
>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: typography.callout - 2,
|
||||||
|
color: "#9CA3AF",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import React from "react";
|
import 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVLanguageCardProps {
|
export interface TVLanguageCardProps {
|
||||||
@@ -15,6 +15,8 @@ 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 });
|
||||||
|
|
||||||
@@ -72,26 +74,27 @@ export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||||
languageCard: {
|
StyleSheet.create({
|
||||||
width: 120,
|
languageCard: {
|
||||||
height: 60,
|
width: 120,
|
||||||
borderRadius: 12,
|
height: 60,
|
||||||
justifyContent: "center",
|
borderRadius: 12,
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: 12,
|
alignItems: "center",
|
||||||
},
|
paddingHorizontal: 12,
|
||||||
languageCardText: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
languageCardText: {
|
||||||
fontWeight: "500",
|
fontSize: typography.callout,
|
||||||
},
|
fontWeight: "500",
|
||||||
languageCardCode: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
languageCardCode: {
|
||||||
marginTop: 2,
|
fontSize: typography.callout,
|
||||||
},
|
marginTop: 2,
|
||||||
checkmark: {
|
},
|
||||||
position: "absolute",
|
checkmark: {
|
||||||
top: 8,
|
position: "absolute",
|
||||||
right: 8,
|
top: 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVMetadataBadgesProps {
|
export interface TVMetadataBadgesProps {
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
@@ -14,6 +14,8 @@ 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={{
|
||||||
@@ -25,12 +27,12 @@ export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{year != null && (
|
{year != null && (
|
||||||
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
<Text style={{ color: "white", fontSize: typography.body }}>
|
||||||
{year}
|
{year}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{duration && (
|
{duration && (
|
||||||
<Text style={{ color: "white", fontSize: TVTypography.body }}>
|
<Text style={{ color: "white", fontSize: typography.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, useRef } from "react";
|
import { type FC, useEffect, useMemo, 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
|
||||||
export interface TVNextEpisodeCountdownProps {
|
export interface TVNextEpisodeCountdownProps {
|
||||||
@@ -31,6 +31,7 @@ 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);
|
||||||
@@ -69,6 +70,8 @@ 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 (
|
||||||
@@ -105,57 +108,58 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||||
container: {
|
StyleSheet.create({
|
||||||
position: "absolute",
|
container: {
|
||||||
bottom: 180,
|
position: "absolute",
|
||||||
right: 80,
|
bottom: 180,
|
||||||
zIndex: 100,
|
right: 80,
|
||||||
},
|
zIndex: 100,
|
||||||
blur: {
|
},
|
||||||
borderRadius: 16,
|
blur: {
|
||||||
overflow: "hidden",
|
borderRadius: 16,
|
||||||
},
|
overflow: "hidden",
|
||||||
innerContainer: {
|
},
|
||||||
flexDirection: "row",
|
innerContainer: {
|
||||||
alignItems: "stretch",
|
flexDirection: "row",
|
||||||
},
|
alignItems: "stretch",
|
||||||
thumbnail: {
|
},
|
||||||
width: 180,
|
thumbnail: {
|
||||||
backgroundColor: "rgba(0,0,0,0.3)",
|
width: 180,
|
||||||
},
|
backgroundColor: "rgba(0,0,0,0.3)",
|
||||||
content: {
|
},
|
||||||
padding: 16,
|
content: {
|
||||||
justifyContent: "center",
|
padding: 16,
|
||||||
width: 280,
|
justifyContent: "center",
|
||||||
},
|
width: 280,
|
||||||
label: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
label: {
|
||||||
color: "rgba(255,255,255,0.5)",
|
fontSize: typography.callout,
|
||||||
textTransform: "uppercase",
|
color: "rgba(255,255,255,0.5)",
|
||||||
letterSpacing: 1,
|
textTransform: "uppercase",
|
||||||
marginBottom: 4,
|
letterSpacing: 1,
|
||||||
},
|
marginBottom: 4,
|
||||||
seriesName: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
seriesName: {
|
||||||
color: "rgba(255,255,255,0.7)",
|
fontSize: typography.callout,
|
||||||
marginBottom: 2,
|
color: "rgba(255,255,255,0.7)",
|
||||||
},
|
marginBottom: 2,
|
||||||
episodeInfo: {
|
},
|
||||||
fontSize: TVTypography.body,
|
episodeInfo: {
|
||||||
color: "#fff",
|
fontSize: typography.body,
|
||||||
fontWeight: "600",
|
color: "#fff",
|
||||||
marginBottom: 12,
|
fontWeight: "600",
|
||||||
},
|
marginBottom: 12,
|
||||||
progressContainer: {
|
},
|
||||||
height: 4,
|
progressContainer: {
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
height: 4,
|
||||||
borderRadius: 2,
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
overflow: "hidden",
|
borderRadius: 2,
|
||||||
},
|
overflow: "hidden",
|
||||||
progressBar: {
|
},
|
||||||
height: "100%",
|
progressBar: {
|
||||||
backgroundColor: "#fff",
|
height: "100%",
|
||||||
borderRadius: 2,
|
backgroundColor: "#fff",
|
||||||
},
|
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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVOptionButtonProps {
|
export interface TVOptionButtonProps {
|
||||||
@@ -14,6 +14,7 @@ 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 });
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#444",
|
color: "#444",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -58,7 +59,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#000",
|
color: "#000",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
}}
|
}}
|
||||||
@@ -88,7 +89,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#bbb",
|
color: "#bbb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -96,7 +97,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVOptionCardProps {
|
export interface TVOptionCardProps {
|
||||||
@@ -28,6 +28,7 @@ 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 });
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: focused ? "#000" : "#fff",
|
color: focused ? "#000" : "#fff",
|
||||||
fontWeight: focused || selected ? "600" : "400",
|
fontWeight: focused || selected ? "600" : "400",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
@@ -71,7 +72,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
|
|||||||
{sublabel && (
|
{sublabel && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { TVCancelButton } from "./TVCancelButton";
|
import { TVCancelButton } from "./TVCancelButton";
|
||||||
import { TVOptionCard } from "./TVOptionCard";
|
import { TVOptionCard } from "./TVOptionCard";
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ 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);
|
||||||
|
|
||||||
@@ -91,6 +92,8 @@ export const TVOptionSelector = <T,>({
|
|||||||
}
|
}
|
||||||
}, [isReady]);
|
}, [isReady]);
|
||||||
|
|
||||||
|
const styles = useMemo(() => createStyles(typography), [typography]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,50 +154,51 @@ export const TVOptionSelector = <T,>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||||
overlay: {
|
StyleSheet.create({
|
||||||
position: "absolute",
|
overlay: {
|
||||||
top: 0,
|
position: "absolute",
|
||||||
left: 0,
|
top: 0,
|
||||||
right: 0,
|
left: 0,
|
||||||
bottom: 0,
|
right: 0,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
bottom: 0,
|
||||||
justifyContent: "flex-end",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
zIndex: 1000,
|
justifyContent: "flex-end",
|
||||||
},
|
zIndex: 1000,
|
||||||
sheetContainer: {
|
},
|
||||||
width: "100%",
|
sheetContainer: {
|
||||||
},
|
width: "100%",
|
||||||
blurContainer: {
|
},
|
||||||
borderTopLeftRadius: 24,
|
blurContainer: {
|
||||||
borderTopRightRadius: 24,
|
borderTopLeftRadius: 24,
|
||||||
overflow: "hidden",
|
borderTopRightRadius: 24,
|
||||||
},
|
overflow: "hidden",
|
||||||
content: {
|
},
|
||||||
paddingTop: 24,
|
content: {
|
||||||
paddingBottom: 50,
|
paddingTop: 24,
|
||||||
overflow: "visible",
|
paddingBottom: 50,
|
||||||
},
|
overflow: "visible",
|
||||||
title: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
title: {
|
||||||
fontWeight: "500",
|
fontSize: typography.callout,
|
||||||
color: "rgba(255,255,255,0.6)",
|
fontWeight: "500",
|
||||||
marginBottom: 16,
|
color: "rgba(255,255,255,0.6)",
|
||||||
paddingHorizontal: 48,
|
marginBottom: 16,
|
||||||
textTransform: "uppercase",
|
paddingHorizontal: 48,
|
||||||
letterSpacing: 1,
|
textTransform: "uppercase",
|
||||||
},
|
letterSpacing: 1,
|
||||||
scrollView: {
|
},
|
||||||
overflow: "visible",
|
scrollView: {
|
||||||
},
|
overflow: "visible",
|
||||||
scrollContent: {
|
},
|
||||||
paddingHorizontal: 48,
|
scrollContent: {
|
||||||
paddingVertical: 20,
|
paddingHorizontal: 48,
|
||||||
gap: 12,
|
paddingVertical: 20,
|
||||||
},
|
gap: 12,
|
||||||
cancelButtonContainer: {
|
},
|
||||||
marginTop: 16,
|
cancelButtonContainer: {
|
||||||
paddingHorizontal: 48,
|
marginTop: 16,
|
||||||
alignItems: "flex-start",
|
paddingHorizontal: 48,
|
||||||
},
|
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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
|
||||||
|
|
||||||
export interface TVSeriesNavigationProps {
|
export interface TVSeriesNavigationProps {
|
||||||
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -27,7 +28,7 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
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, Pressable, View } from "react-native";
|
import { Animated, Platform, Pressable, View } from "react-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
import {
|
||||||
|
GlassPosterView,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "@/modules/glass-poster";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSeriesSeasonCardProps {
|
export interface TVSeriesSeasonCardProps {
|
||||||
@@ -21,9 +25,63 @@ 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}
|
||||||
@@ -38,45 +96,16 @@ 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: focused ? 0.5 : 0,
|
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
|
||||||
shadowRadius: focused ? 20 : 0,
|
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
{renderPoster()}
|
||||||
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: TVTypography.body,
|
fontSize: typography.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",
|
||||||
@@ -90,7 +119,7 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
|
|||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +23,8 @@ 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 });
|
||||||
|
|
||||||
@@ -197,72 +199,73 @@ export const TVSubtitleResultCard = React.forwardRef<
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||||
resultCard: {
|
StyleSheet.create({
|
||||||
width: 220,
|
resultCard: {
|
||||||
minHeight: 120,
|
width: 220,
|
||||||
borderRadius: 14,
|
minHeight: 120,
|
||||||
padding: 14,
|
borderRadius: 14,
|
||||||
borderWidth: 1,
|
padding: 14,
|
||||||
},
|
borderWidth: 1,
|
||||||
providerBadge: {
|
},
|
||||||
alignSelf: "flex-start",
|
providerBadge: {
|
||||||
paddingHorizontal: 8,
|
alignSelf: "flex-start",
|
||||||
paddingVertical: 3,
|
paddingHorizontal: 8,
|
||||||
borderRadius: 6,
|
paddingVertical: 3,
|
||||||
marginBottom: 8,
|
borderRadius: 6,
|
||||||
},
|
marginBottom: 8,
|
||||||
providerText: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
providerText: {
|
||||||
fontWeight: "600",
|
fontSize: typography.callout,
|
||||||
textTransform: "uppercase",
|
fontWeight: "600",
|
||||||
letterSpacing: 0.5,
|
textTransform: "uppercase",
|
||||||
},
|
letterSpacing: 0.5,
|
||||||
resultName: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
resultName: {
|
||||||
fontWeight: "500",
|
fontSize: typography.callout,
|
||||||
marginBottom: 8,
|
fontWeight: "500",
|
||||||
lineHeight: 18,
|
marginBottom: 8,
|
||||||
},
|
lineHeight: 18,
|
||||||
resultMeta: {
|
},
|
||||||
flexDirection: "row",
|
resultMeta: {
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
gap: 12,
|
alignItems: "center",
|
||||||
marginBottom: 8,
|
gap: 12,
|
||||||
},
|
marginBottom: 8,
|
||||||
resultMetaText: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
resultMetaText: {
|
||||||
},
|
fontSize: typography.callout,
|
||||||
ratingContainer: {
|
},
|
||||||
flexDirection: "row",
|
ratingContainer: {
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
gap: 3,
|
alignItems: "center",
|
||||||
},
|
gap: 3,
|
||||||
downloadCountContainer: {
|
},
|
||||||
flexDirection: "row",
|
downloadCountContainer: {
|
||||||
alignItems: "center",
|
flexDirection: "row",
|
||||||
gap: 3,
|
alignItems: "center",
|
||||||
},
|
gap: 3,
|
||||||
flagsContainer: {
|
},
|
||||||
flexDirection: "row",
|
flagsContainer: {
|
||||||
gap: 6,
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
gap: 6,
|
||||||
},
|
flexWrap: "wrap",
|
||||||
flag: {
|
},
|
||||||
paddingHorizontal: 6,
|
flag: {
|
||||||
paddingVertical: 2,
|
paddingHorizontal: 6,
|
||||||
borderRadius: 4,
|
paddingVertical: 2,
|
||||||
},
|
borderRadius: 4,
|
||||||
flagText: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
flagText: {
|
||||||
fontWeight: "600",
|
fontSize: typography.callout,
|
||||||
color: "#fff",
|
fontWeight: "600",
|
||||||
},
|
color: "#fff",
|
||||||
downloadingOverlay: {
|
},
|
||||||
...StyleSheet.absoluteFillObject,
|
downloadingOverlay: {
|
||||||
backgroundColor: "rgba(0,0,0,0.5)",
|
...StyleSheet.absoluteFillObject,
|
||||||
borderRadius: 14,
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
justifyContent: "center",
|
borderRadius: 14,
|
||||||
alignItems: "center",
|
justifyContent: "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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVTabButtonProps {
|
export interface TVTabButtonProps {
|
||||||
@@ -21,6 +21,7 @@ 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,
|
||||||
@@ -56,7 +57,7 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
|
|
||||||
export interface TVTechnicalDetailsProps {
|
export interface TVTechnicalDetailsProps {
|
||||||
mediaStreams: MediaStream[];
|
mediaStreams: MediaStream[];
|
||||||
@@ -11,6 +11,7 @@ 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");
|
||||||
@@ -24,7 +25,7 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
|||||||
<View style={{ marginBottom: 32 }}>
|
<View style={{ marginBottom: 32 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.heading,
|
fontSize: typography.heading,
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
@@ -37,16 +38,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Video
|
{t("common.video")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.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>
|
||||||
@@ -56,16 +57,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
|
|||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Audio
|
{t("common.audio")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVTrackCardProps {
|
export interface TVTrackCardProps {
|
||||||
@@ -15,6 +15,8 @@ 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 });
|
||||||
|
|
||||||
@@ -77,26 +79,27 @@ export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
|
||||||
trackCard: {
|
StyleSheet.create({
|
||||||
width: 180,
|
trackCard: {
|
||||||
height: 80,
|
width: 180,
|
||||||
borderRadius: 14,
|
height: 80,
|
||||||
justifyContent: "center",
|
borderRadius: 14,
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: 12,
|
alignItems: "center",
|
||||||
},
|
paddingHorizontal: 12,
|
||||||
trackCardText: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
trackCardText: {
|
||||||
textAlign: "center",
|
fontSize: typography.callout,
|
||||||
},
|
textAlign: "center",
|
||||||
trackCardSublabel: {
|
},
|
||||||
fontSize: TVTypography.callout,
|
trackCardSublabel: {
|
||||||
marginTop: 2,
|
fontSize: typography.callout,
|
||||||
},
|
marginTop: 2,
|
||||||
checkmark: {
|
},
|
||||||
position: "absolute",
|
checkmark: {
|
||||||
top: 8,
|
position: "absolute",
|
||||||
right: 8,
|
top: 8,
|
||||||
},
|
right: 8,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVLogoutButtonProps {
|
export interface TVLogoutButtonProps {
|
||||||
@@ -15,6 +15,7 @@ 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 });
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.body,
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
color: "#FFFFFF",
|
color: "#FFFFFF",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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 }) => {
|
||||||
<Text
|
const typography = useScaledTVTypography();
|
||||||
style={{
|
|
||||||
fontSize: TVTypography.callout,
|
return (
|
||||||
fontWeight: "600",
|
<Text
|
||||||
color: "#9CA3AF",
|
style={{
|
||||||
textTransform: "uppercase",
|
fontSize: typography.callout,
|
||||||
letterSpacing: 1,
|
fontWeight: "600",
|
||||||
marginTop: 32,
|
color: "#9CA3AF",
|
||||||
marginBottom: 16,
|
textTransform: "uppercase",
|
||||||
marginLeft: 8,
|
letterSpacing: 1,
|
||||||
}}
|
marginTop: 32,
|
||||||
>
|
marginBottom: 16,
|
||||||
{title}
|
marginLeft: 8,
|
||||||
</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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsOptionButtonProps {
|
export interface TVSettingsOptionButtonProps {
|
||||||
@@ -20,6 +20,7 @@ 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 });
|
||||||
|
|
||||||
@@ -49,13 +50,13 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsRowProps {
|
export interface TVSettingsRowProps {
|
||||||
@@ -22,6 +22,7 @@ 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 });
|
||||||
|
|
||||||
@@ -51,13 +52,13 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsStepperProps {
|
export interface TVSettingsStepperProps {
|
||||||
@@ -24,6 +24,7 @@ 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 });
|
||||||
@@ -54,7 +55,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
|||||||
focusable={!disabled}
|
focusable={!disabled}
|
||||||
>
|
>
|
||||||
<Animated.View style={labelAnim.animatedStyle}>
|
<Animated.View style={labelAnim.animatedStyle}>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -89,7 +90,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsTextInputProps {
|
export interface TVSettingsTextInputProps {
|
||||||
@@ -23,6 +23,7 @@ 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 });
|
||||||
@@ -56,7 +57,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
|||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.callout,
|
fontSize: typography.callout,
|
||||||
color: "#9CA3AF",
|
color: "#9CA3AF",
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
@@ -74,7 +75,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
|
|||||||
autoCapitalize='none'
|
autoCapitalize='none'
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
style={{
|
style={{
|
||||||
fontSize: TVTypography.body,
|
fontSize: typography.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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||||
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
|
||||||
|
|
||||||
export interface TVSettingsToggleProps {
|
export interface TVSettingsToggleProps {
|
||||||
@@ -19,6 +19,7 @@ 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 });
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
|
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<View
|
<View
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, TVFocusGuideView, View } from "react-native";
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
TVFocusGuideView,
|
||||||
|
useWindowDimensions,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
@@ -26,8 +31,9 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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";
|
||||||
@@ -82,6 +88,96 @@ 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,
|
||||||
@@ -111,8 +207,17 @@ 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();
|
||||||
@@ -271,6 +376,15 @@ 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);
|
||||||
@@ -831,15 +945,17 @@ export const Controls: FC<Props> = ({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{showSeekBubble && (
|
<View style={styles.trickplayBubbleContainer}>
|
||||||
<View style={styles.trickplayBubbleContainer}>
|
<TVTrickplayBubblePositioned
|
||||||
<TrickplayBubble
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickPlayUrl={trickPlayUrl}
|
trickplayInfo={trickplayInfo}
|
||||||
trickplayInfo={trickplayInfo}
|
time={seekBubbleTime}
|
||||||
time={seekBubbleTime}
|
progress={effectiveProgress}
|
||||||
/>
|
max={max}
|
||||||
</View>
|
progressBarWidth={progressBarWidth}
|
||||||
)}
|
visible={showSeekBubble}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Same padding as TVFocusableProgressBar for alignment */}
|
{/* Same padding as TVFocusableProgressBar for alignment */}
|
||||||
<View style={styles.minimalProgressWrapper}>
|
<View style={styles.minimalProgressWrapper}>
|
||||||
@@ -868,14 +984,16 @@ export const Controls: FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.timeContainer}>
|
<View style={styles.timeContainer}>
|
||||||
<Text style={styles.timeText}>
|
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||||
{formatTimeString(currentTime, "ms")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.timeRight}>
|
<View style={styles.timeRight}>
|
||||||
<Text style={styles.timeText}>
|
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.endsAtText}>
|
<Text
|
||||||
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -901,12 +1019,18 @@ export const Controls: FC<Props> = ({
|
|||||||
<View style={styles.metadataContainer}>
|
<View style={styles.metadataContainer}>
|
||||||
{item?.Type === "Episode" && (
|
{item?.Type === "Episode" && (
|
||||||
<Text
|
<Text
|
||||||
style={styles.subtitleText}
|
style={[styles.subtitleText, { fontSize: typography.body }]}
|
||||||
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.titleText}>{item?.Name}</Text>
|
<Text style={[styles.titleText, { fontSize: typography.heading }]}>
|
||||||
|
{item?.Name}
|
||||||
|
</Text>
|
||||||
{item?.Type === "Movie" && (
|
{item?.Type === "Movie" && (
|
||||||
<Text style={styles.subtitleText}>{item?.ProductionYear}</Text>
|
<Text
|
||||||
|
style={[styles.subtitleText, { fontSize: typography.body }]}
|
||||||
|
>
|
||||||
|
{item?.ProductionYear}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -931,6 +1055,12 @@ 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} />
|
||||||
|
|
||||||
@@ -963,15 +1093,17 @@ export const Controls: FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showSeekBubble && (
|
<View style={styles.trickplayBubbleContainer}>
|
||||||
<View style={styles.trickplayBubbleContainer}>
|
<TVTrickplayBubblePositioned
|
||||||
<TrickplayBubble
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickPlayUrl={trickPlayUrl}
|
trickplayInfo={trickplayInfo}
|
||||||
trickplayInfo={trickplayInfo}
|
time={seekBubbleTime}
|
||||||
time={seekBubbleTime}
|
progress={effectiveProgress}
|
||||||
/>
|
max={max}
|
||||||
</View>
|
progressBarWidth={progressBarWidth}
|
||||||
)}
|
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 */}
|
||||||
@@ -1003,14 +1135,16 @@ export const Controls: FC<Props> = ({
|
|||||||
</TVFocusGuideView>
|
</TVFocusGuideView>
|
||||||
|
|
||||||
<View style={styles.timeContainer}>
|
<View style={styles.timeContainer}>
|
||||||
<Text style={styles.timeText}>
|
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||||
{formatTimeString(currentTime, "ms")}
|
{formatTimeString(currentTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.timeRight}>
|
<View style={styles.timeRight}>
|
||||||
<Text style={styles.timeText}>
|
<Text style={[styles.timeText, { fontSize: typography.body }]}>
|
||||||
-{formatTimeString(remainingTime, "ms")}
|
-{formatTimeString(remainingTime, "ms")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.endsAtText}>
|
<Text
|
||||||
|
style={[styles.endsAtText, { fontSize: typography.callout }]}
|
||||||
|
>
|
||||||
{t("player.ends_at")} {getFinishTime()}
|
{t("player.ends_at")} {getFinishTime()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -1044,11 +1178,9 @@ 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: {
|
||||||
@@ -1063,12 +1195,15 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
trickplayBubbleContainer: {
|
trickplayBubbleContainer: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 120,
|
bottom: 170,
|
||||||
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%",
|
||||||
@@ -1108,7 +1243,6 @@ 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",
|
||||||
@@ -1116,7 +1250,6 @@ 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 { TVTypography } from "@/constants/TVTypography";
|
import { useScaledTVTypography } 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,6 +183,7 @@ 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);
|
||||||
@@ -277,8 +278,15 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
|
|||||||
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText;
|
const textStyle = Platform.isTV
|
||||||
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 (
|
||||||
@@ -383,9 +391,7 @@ 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",
|
||||||
@@ -396,6 +402,5 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
reasonTextTV: {
|
reasonTextTV: {
|
||||||
color: "#fbbf24",
|
color: "#fbbf24",
|
||||||
fontSize: TVTypography.callout,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ 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;
|
||||||
@@ -22,12 +26,21 @@ 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;
|
||||||
@@ -36,16 +49,17 @@ 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: -62,
|
left: -BUBBLE_LEFT_OFFSET * imageScale,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
paddingTop: 30,
|
paddingTop: 30,
|
||||||
paddingBottom: 5,
|
paddingBottom: 5,
|
||||||
width: tileWidth * 1.5,
|
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
@@ -55,13 +69,13 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
width: tileWidth,
|
width: tileWidth,
|
||||||
height: tileHeight,
|
height: tileHeight,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
transform: [{ scale: 1.4 }],
|
transform: [{ scale: finalScale }],
|
||||||
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:
|
||||||
@@ -76,15 +90,8 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
|
|||||||
contentFit='cover'
|
contentFit='cover'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text style={{ marginTop: 30, fontSize: 16 }}>
|
||||||
style={{
|
{formatTime(time.hours, time.minutes, time.seconds)}
|
||||||
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,3 +1,5 @@
|
|||||||
|
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TV Typography Scale
|
* TV Typography Scale
|
||||||
*
|
*
|
||||||
@@ -23,3 +25,29 @@ 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,6 +2,7 @@ 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";
|
||||||
|
|
||||||
@@ -49,6 +50,8 @@ 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,
|
||||||
|
|||||||
6
modules/glass-poster/expo-module.config.json
Normal file
6
modules/glass-poster/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["GlassPosterModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
8
modules/glass-poster/index.ts
Normal file
8
modules/glass-poster/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Glass Poster - Native SwiftUI glass effect for tvOS 26+
|
||||||
|
|
||||||
|
export * from "./src/GlassPoster.types";
|
||||||
|
export {
|
||||||
|
default as GlassPosterModule,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "./src/GlassPosterModule";
|
||||||
|
export { default as GlassPosterView } from "./src/GlassPosterView";
|
||||||
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
23
modules/glass-poster/ios/GlassPoster.podspec
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'GlassPoster'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Native SwiftUI glass effect poster for tvOS'
|
||||||
|
s.description = 'Provides Liquid Glass effect poster cards for tvOS 26+'
|
||||||
|
s.author = 'Streamyfin'
|
||||||
|
s.homepage = 'https://github.com/streamyfin/streamyfin'
|
||||||
|
s.platforms = {
|
||||||
|
:ios => '15.1',
|
||||||
|
:tvos => '15.1'
|
||||||
|
}
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_VERSION' => '5.9'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "*.{h,m,mm,swift}"
|
||||||
|
end
|
||||||
91
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
91
modules/glass-poster/ios/GlassPosterExpoView.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// ExpoView wrapper that hosts the SwiftUI GlassPosterView
|
||||||
|
class GlassPosterExpoView: ExpoView {
|
||||||
|
private var hostingController: UIHostingController<GlassPosterView>?
|
||||||
|
private var posterView: GlassPosterView
|
||||||
|
|
||||||
|
// Stored dimensions for intrinsic content size
|
||||||
|
private var posterWidth: CGFloat = 260
|
||||||
|
private var posterAspectRatio: CGFloat = 10.0 / 15.0
|
||||||
|
|
||||||
|
// Event dispatchers
|
||||||
|
let onLoad = EventDispatcher()
|
||||||
|
let onError = EventDispatcher()
|
||||||
|
|
||||||
|
required init(appContext: AppContext? = nil) {
|
||||||
|
self.posterView = GlassPosterView()
|
||||||
|
super.init(appContext: appContext)
|
||||||
|
setupHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupHostingController() {
|
||||||
|
let hostingController = UIHostingController(rootView: posterView)
|
||||||
|
hostingController.view.backgroundColor = .clear
|
||||||
|
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
addSubview(hostingController.view)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
self.hostingController = hostingController
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateHostingController() {
|
||||||
|
hostingController?.rootView = posterView
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override intrinsic content size for proper React Native layout
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
let height = posterWidth / posterAspectRatio
|
||||||
|
return CGSize(width: posterWidth, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Property Setters
|
||||||
|
|
||||||
|
func setImageUrl(_ url: String?) {
|
||||||
|
posterView.imageUrl = url
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAspectRatio(_ ratio: Double) {
|
||||||
|
posterView.aspectRatio = ratio
|
||||||
|
posterAspectRatio = CGFloat(ratio)
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWidth(_ width: Double) {
|
||||||
|
posterView.width = width
|
||||||
|
posterWidth = CGFloat(width)
|
||||||
|
invalidateIntrinsicContentSize()
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCornerRadius(_ radius: Double) {
|
||||||
|
posterView.cornerRadius = radius
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProgress(_ progress: Double) {
|
||||||
|
posterView.progress = progress
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setShowWatchedIndicator(_ show: Bool) {
|
||||||
|
posterView.showWatchedIndicator = show
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setIsFocused(_ focused: Bool) {
|
||||||
|
posterView.isFocused = focused
|
||||||
|
updateHostingController()
|
||||||
|
}
|
||||||
|
}
|
||||||
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
50
modules/glass-poster/ios/GlassPosterModule.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class GlassPosterModule: Module {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("GlassPoster")
|
||||||
|
|
||||||
|
// Check if glass effect is available (tvOS 26+)
|
||||||
|
Function("isGlassEffectAvailable") { () -> Bool in
|
||||||
|
#if os(tvOS)
|
||||||
|
if #available(tvOS 26.0, *) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native view component
|
||||||
|
View(GlassPosterExpoView.self) {
|
||||||
|
Prop("imageUrl") { (view: GlassPosterExpoView, url: String?) in
|
||||||
|
view.setImageUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("aspectRatio") { (view: GlassPosterExpoView, ratio: Double) in
|
||||||
|
view.setAspectRatio(ratio)
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("cornerRadius") { (view: GlassPosterExpoView, radius: Double) in
|
||||||
|
view.setCornerRadius(radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("progress") { (view: GlassPosterExpoView, progress: Double) in
|
||||||
|
view.setProgress(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("showWatchedIndicator") { (view: GlassPosterExpoView, show: Bool) in
|
||||||
|
view.setShowWatchedIndicator(show)
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("isFocused") { (view: GlassPosterExpoView, focused: Bool) in
|
||||||
|
view.setIsFocused(focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
Prop("width") { (view: GlassPosterExpoView, width: Double) in
|
||||||
|
view.setWidth(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
Events("onLoad", "onError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
modules/glass-poster/ios/GlassPosterView.swift
Normal file
195
modules/glass-poster/ios/GlassPosterView.swift
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// SwiftUI view with tvOS 26 Liquid Glass effect
|
||||||
|
struct GlassPosterView: View {
|
||||||
|
var imageUrl: String? = nil
|
||||||
|
var aspectRatio: Double = 10.0 / 15.0
|
||||||
|
var cornerRadius: Double = 24
|
||||||
|
var progress: Double = 0
|
||||||
|
var showWatchedIndicator: Bool = false
|
||||||
|
var isFocused: Bool = false
|
||||||
|
var width: Double = 260
|
||||||
|
|
||||||
|
// Internal focus state for tvOS
|
||||||
|
@FocusState private var isInternallyFocused: Bool
|
||||||
|
|
||||||
|
// Combined focus state (external prop OR internal focus)
|
||||||
|
private var isCurrentlyFocused: Bool {
|
||||||
|
isFocused || isInternallyFocused
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculated height based on width and aspect ratio
|
||||||
|
private var height: Double {
|
||||||
|
width / aspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
if #available(tvOS 26.0, *) {
|
||||||
|
glassContent
|
||||||
|
} else {
|
||||||
|
fallbackContent
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
fallbackContent
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - tvOS 26+ Glass Effect
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
@available(tvOS 26.0, *)
|
||||||
|
private var glassContent: some View {
|
||||||
|
return ZStack {
|
||||||
|
// Image content
|
||||||
|
imageContent
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
|
||||||
|
// Progress bar overlay
|
||||||
|
if progress > 0 {
|
||||||
|
progressOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watched indicator
|
||||||
|
if showWatchedIndicator {
|
||||||
|
watchedIndicatorOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
.focusable()
|
||||||
|
.focused($isInternallyFocused)
|
||||||
|
.scaleEffect(isCurrentlyFocused ? 1.08 : 1.0)
|
||||||
|
.animation(.easeOut(duration: 0.15), value: isCurrentlyFocused)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Fallback for older tvOS versions
|
||||||
|
|
||||||
|
private var fallbackContent: some View {
|
||||||
|
ZStack {
|
||||||
|
// Main image
|
||||||
|
imageContent
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
|
||||||
|
// Subtle overlay for depth
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(.ultraThinMaterial.opacity(0.15))
|
||||||
|
|
||||||
|
// Progress bar overlay
|
||||||
|
if progress > 0 {
|
||||||
|
progressOverlay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watched indicator
|
||||||
|
if showWatchedIndicator {
|
||||||
|
watchedIndicatorOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.scaleEffect(isFocused ? 1.08 : 1.0)
|
||||||
|
.animation(.easeOut(duration: 0.15), value: isFocused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared Components
|
||||||
|
|
||||||
|
private var imageContent: some View {
|
||||||
|
Group {
|
||||||
|
if let urlString = imageUrl, let url = URL(string: urlString) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
placeholderView
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
case .failure:
|
||||||
|
placeholderView
|
||||||
|
@unknown default:
|
||||||
|
placeholderView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholderView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholderView: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var progressOverlay: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
// Background track
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.white.opacity(0.3))
|
||||||
|
.frame(height: 4)
|
||||||
|
|
||||||
|
// Progress fill
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.white)
|
||||||
|
.frame(width: geometry.size.width * CGFloat(progress / 100), height: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var watchedIndicatorOverlay: some View {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white.opacity(0.9))
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
struct GlassPosterView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
GlassPosterView(
|
||||||
|
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
|
||||||
|
aspectRatio: 10.0 / 15.0,
|
||||||
|
cornerRadius: 24,
|
||||||
|
progress: 45,
|
||||||
|
showWatchedIndicator: false,
|
||||||
|
isFocused: true,
|
||||||
|
width: 260
|
||||||
|
)
|
||||||
|
|
||||||
|
GlassPosterView(
|
||||||
|
imageUrl: "https://image.tmdb.org/t/p/w500/example.jpg",
|
||||||
|
aspectRatio: 16.0 / 9.0,
|
||||||
|
cornerRadius: 24,
|
||||||
|
progress: 75,
|
||||||
|
showWatchedIndicator: true,
|
||||||
|
isFocused: false,
|
||||||
|
width: 400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
26
modules/glass-poster/src/GlassPoster.types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
|
|
||||||
|
export interface GlassPosterViewProps {
|
||||||
|
/** URL of the image to display */
|
||||||
|
imageUrl: string | null;
|
||||||
|
/** Aspect ratio of the poster (width/height). Default: 10/15 for portrait, 16/9 for landscape */
|
||||||
|
aspectRatio: number;
|
||||||
|
/** Corner radius in points. Default: 24 */
|
||||||
|
cornerRadius: number;
|
||||||
|
/** Progress percentage (0-100). Shows progress bar at bottom when > 0 */
|
||||||
|
progress: number;
|
||||||
|
/** Whether to show the watched checkmark indicator */
|
||||||
|
showWatchedIndicator: boolean;
|
||||||
|
/** Whether the poster is currently focused (for scale animation) */
|
||||||
|
isFocused: boolean;
|
||||||
|
/** Width of the poster in points. Required for proper sizing. */
|
||||||
|
width: number;
|
||||||
|
/** Style for the container view */
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/** Called when the image loads successfully */
|
||||||
|
onLoad?: () => void;
|
||||||
|
/** Called when image loading fails */
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlassPosterModuleEvents = Record<string, never>;
|
||||||
36
modules/glass-poster/src/GlassPosterModule.ts
Normal file
36
modules/glass-poster/src/GlassPosterModule.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NativeModule, requireNativeModule } from "expo";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
import type { GlassPosterModuleEvents } from "./GlassPoster.types";
|
||||||
|
|
||||||
|
declare class GlassPosterModuleType extends NativeModule<GlassPosterModuleEvents> {
|
||||||
|
isGlassEffectAvailable(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only load the native module on tvOS
|
||||||
|
let GlassPosterNativeModule: GlassPosterModuleType | null = null;
|
||||||
|
|
||||||
|
if (Platform.OS === "ios" && Platform.isTV) {
|
||||||
|
try {
|
||||||
|
GlassPosterNativeModule =
|
||||||
|
requireNativeModule<GlassPosterModuleType>("GlassPoster");
|
||||||
|
} catch {
|
||||||
|
// Module not available, will use fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the native glass effect is available (tvOS 26+)
|
||||||
|
*/
|
||||||
|
export function isGlassEffectAvailable(): boolean {
|
||||||
|
if (!GlassPosterNativeModule) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return GlassPosterNativeModule.isGlassEffectAvailable();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlassPosterNativeModule;
|
||||||
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
46
modules/glass-poster/src/GlassPosterView.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { requireNativeView } from "expo";
|
||||||
|
import * as React from "react";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
|
||||||
|
import type { GlassPosterViewProps } from "./GlassPoster.types";
|
||||||
|
import { isGlassEffectAvailable } from "./GlassPosterModule";
|
||||||
|
|
||||||
|
// Only require the native view on tvOS
|
||||||
|
let NativeGlassPosterView: React.ComponentType<GlassPosterViewProps> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (Platform.OS === "ios" && Platform.isTV) {
|
||||||
|
try {
|
||||||
|
NativeGlassPosterView =
|
||||||
|
requireNativeView<GlassPosterViewProps>("GlassPoster");
|
||||||
|
} catch {
|
||||||
|
// Module not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlassPosterView - Native SwiftUI glass effect poster for tvOS 26+
|
||||||
|
*
|
||||||
|
* On tvOS 26+: Renders with native Liquid Glass effect
|
||||||
|
* On older tvOS: Renders with subtle glass-like material effect
|
||||||
|
* On other platforms: Returns null (use existing poster components)
|
||||||
|
*/
|
||||||
|
const GlassPosterView: React.FC<GlassPosterViewProps> = (props) => {
|
||||||
|
// Only render on tvOS
|
||||||
|
if (!Platform.isTV || Platform.OS !== "ios") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use native view if available
|
||||||
|
if (NativeGlassPosterView) {
|
||||||
|
return <NativeGlassPosterView {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return empty view (caller should handle this)
|
||||||
|
return <View style={props.style} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlassPosterView;
|
||||||
|
|
||||||
|
// Re-export availability check for convenience
|
||||||
|
export { isGlassEffectAvailable };
|
||||||
6
modules/glass-poster/src/index.ts
Normal file
6
modules/glass-poster/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export * from "./GlassPoster.types";
|
||||||
|
export {
|
||||||
|
default as GlassPosterModule,
|
||||||
|
isGlassEffectAvailable,
|
||||||
|
} from "./GlassPosterModule";
|
||||||
|
export { default as GlassPosterView } from "./GlassPosterView";
|
||||||
@@ -7,7 +7,9 @@ export type {
|
|||||||
DownloadStartedEvent,
|
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,7 +98,6 @@
|
|||||||
"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