Compare commits

..

22 Commits

Author SHA1 Message Date
Lance Chant
715daf1635 feat: skip intro
Added skip intro logic
Updated control button to take an icon or text

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-01-27 09:01:32 +02:00
Fredrik Burmester
44caf4b1ff feat(i18n): add swedish translations for tv interface and fix hardcoded strings 2026-01-26 08:17:00 +01:00
Fredrik Burmester
92c70fadd1 refactor(tv): reorganize item detail page layout and improve episode list 2026-01-26 08:16:59 +01:00
Fredrik Burmester
f637367b82 style(tv): update episode section heading typography and spacing 2026-01-26 07:51:55 +01:00
Fredrik Burmester
715764cef8 feat(tv): add season episode list to episode detail page 2026-01-25 23:23:03 +01:00
Fredrik Burmester
36d6686258 feat(tv): increase typography scale multipliers for better visibility 2026-01-25 23:02:52 +01:00
Fredrik Burmester
dca7cc99f2 feat(tv): add setting to show series poster on episode detail pages 2026-01-25 23:01:08 +01:00
Fredrik Burmester
875a017e8c feat(tv): add scalable typography with user-configurable text size 2026-01-25 22:55:44 +01:00
Fredrik Burmester
0c6c20f563 feat(tv): add horizontal gradient fade to hero carousel backdrop 2026-01-25 22:53:24 +01:00
Fredrik Burmester
2c9906377d feat(tv): update skeleton layout to match swapped poster position 2026-01-25 21:31:37 +01:00
Fredrik Burmester
d5f7a18fe5 chore: docs 2026-01-25 20:19:45 +01:00
Fredrik Burmester
4606b9718e feat(tv): swap layout and add horizontal posters for episodes 2026-01-25 20:18:12 +01:00
Fredrik Burmester
c2d61654b0 feat(tv): add glass poster module and refactor grid layouts 2026-01-25 17:02:10 +01:00
Fredrik Burmester
2c6938c739 fix: design 2026-01-25 14:50:05 +01:00
Fredrik Burmester
1f454c0f12 feat(tv): add Apple TV+ style hero carousel to home page 2026-01-24 23:43:40 +01:00
Fredrik Burmester
c215fda973 feat(tv): add 60s background refresh for idle screens 2026-01-24 10:48:42 +01:00
Fredrik Burmester
a852e2e769 fix(tv): reposition streamystats sections after recently added 2026-01-24 10:35:15 +01:00
Fredrik Burmester
29873e08d7 feat(tv): add shared filter components and collections page support 2026-01-24 10:31:03 +01:00
Fredrik Burmester
5ce5cc2d99 refactor(home): reposition streamystats sections after recently added 2026-01-24 10:29:04 +01:00
Fredrik Burmester
ae5a71ff29 fix(auth): clear react query cache on logout and enable home screen refetch 2026-01-24 10:25:33 +01:00
Fredrik Burmester
0e3e8b8016 Revert "fix: scaling"
This reverts commit 566ff485fb.
2026-01-24 10:05:22 +01:00
Fredrik Burmester
d07a521f60 feat(tv): add trickplay bubble positioning aligned with progress bar 2026-01-23 22:39:44 +01:00
104 changed files with 3647 additions and 3192 deletions

View File

@@ -30,4 +30,14 @@ This file is auto-imported into CLAUDE.md and loaded at the start of each sessio
- **MPV avfoundation-composite-osd ordering**: On tvOS, the `avfoundation-composite-osd` option MUST be set immediately after `vo=avfoundation`, before any `hwdec` options. Skipping or reordering this causes the app to freeze when exiting the player. Set to "no" on tvOS (prevents gray tint), "yes" on iOS (for PiP subtitle support). _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
- **Thread-safe state for stop flags**: When using flags like `isStopping` that control loop termination across threads, the setter must be synchronous (`stateQueue.sync`) not async, otherwise the value may not be visible to other threads in time. _(2026-01-22)_
- **TV modals must use navigation pattern**: On TV, never use overlay/absolute-positioned modals (like `TVOptionSelector` at the page level). They don't handle the back button correctly. Always use the navigation-based modal pattern: Jotai atom + hook that calls `router.push()` + page in `app/(auth)/`. Use the existing `useTVOptionModal` hook and `tv-option-modal.tsx` page for option selection. `TVOptionSelector` is only appropriate as a sub-selector *within* a navigation-based modal page. _(2026-01-24)_
- **TV grid layout pattern**: For TV grids, use ScrollView with flexWrap instead of FlatList/FlashList with numColumns. FlatList's numColumns divides width evenly among columns which causes inconsistent item sizing. Use `flexDirection: "row"`, `flexWrap: "wrap"`, `justifyContent: "center"`, and `gap` for spacing. _(2026-01-25)_
- **TV horizontal padding standard**: TV pages should use `TV_HORIZONTAL_PADDING = 60` to match other TV pages like Home, Search, etc. The old `TV_SCALE_PADDING = 20` was too small. _(2026-01-25)_
- **Native SwiftUI view sizing**: When creating Expo native modules with SwiftUI views, the view needs explicit dimensions. Use a `width` prop passed from React Native, set an explicit `.frame(width:height:)` in SwiftUI, and override `intrinsicContentSize` in the ExpoView wrapper to report the correct size to React Native's layout system. Using `.aspectRatio(contentMode: .fit)` alone causes inconsistent sizing. _(2026-01-25)_
- **Streamystats components location**: Streamystats TV components are at `components/home/StreamystatsRecommendations.tv.tsx` and `components/home/StreamystatsPromotedWatchlists.tv.tsx`. The watchlist detail page (which shows items in a grid) is at `app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx`. _(2026-01-25)_

View File

@@ -1,15 +1,6 @@
import { useSettings } from "@/utils/atoms/settings";
import { Home } from "../../../../components/home/Home";
import { HomeWithCarousel } from "../../../../components/home/HomeWithCarousel";
const Index = () => {
const { settings } = useSettings();
const showLargeHomeCarousel = settings.showLargeHomeCarousel ?? false;
if (showLargeHomeCarousel) {
return <HomeWithCarousel />;
}
return <Home />;
};

View File

@@ -15,9 +15,14 @@ import {
TVSettingsTextInput,
TVSettingsToggle,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
import {
AudioTranscodeMode,
TVTypographyScale,
useSettings,
} from "@/utils/atoms/settings";
export default function SettingsTV() {
const { t } = useTranslation();
@@ -27,6 +32,7 @@ export default function SettingsTV() {
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal();
const typography = useScaledTVTypography();
// Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -39,6 +45,8 @@ export default function SettingsTV() {
settings.subtitleMode || SubtitlePlaybackMode.Default;
const currentAlignX = settings.mpvSubtitleAlignX ?? "center";
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
const currentTypographyScale =
settings.tvTypographyScale || TVTypographyScale.Default;
// Audio transcoding options
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
@@ -130,6 +138,33 @@ export default function SettingsTV() {
[currentAlignY],
);
// Typography scale options
const typographyScaleOptions: TVOptionItem<TVTypographyScale>[] = useMemo(
() => [
{
label: t("home.settings.appearance.text_size_small"),
value: TVTypographyScale.Small,
selected: currentTypographyScale === TVTypographyScale.Small,
},
{
label: t("home.settings.appearance.text_size_default"),
value: TVTypographyScale.Default,
selected: currentTypographyScale === TVTypographyScale.Default,
},
{
label: t("home.settings.appearance.text_size_large"),
value: TVTypographyScale.Large,
selected: currentTypographyScale === TVTypographyScale.Large,
},
{
label: t("home.settings.appearance.text_size_extra_large"),
value: TVTypographyScale.ExtraLarge,
selected: currentTypographyScale === TVTypographyScale.ExtraLarge,
},
],
[t, currentTypographyScale],
);
// Get display labels for option buttons
const audioTranscodeLabel = useMemo(() => {
const option = audioTranscodeModeOptions.find((o) => o.selected);
@@ -151,6 +186,11 @@ export default function SettingsTV() {
return option?.label || "Bottom";
}, [alignYOptions]);
const typographyScaleLabel = useMemo(() => {
const option = typographyScaleOptions.find((o) => o.selected);
return option?.label || t("home.settings.appearance.text_size_default");
}, [typographyScaleOptions, t]);
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
<View style={{ flex: 1 }}>
@@ -166,7 +206,7 @@ export default function SettingsTV() {
{/* Header */}
<Text
style={{
fontSize: 42,
fontSize: typography.title,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -309,7 +349,7 @@ export default function SettingsTV() {
<Text
style={{
color: "#9CA3AF",
fontSize: 14,
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
@@ -333,7 +373,7 @@ export default function SettingsTV() {
<Text
style={{
color: "#6B7280",
fontSize: 12,
fontSize: typography.callout - 4,
marginTop: 8,
marginLeft: 8,
}}
@@ -344,6 +384,18 @@ export default function SettingsTV() {
{/* Appearance Section */}
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsOptionButton
label={t("home.settings.appearance.text_size")}
value={typographyScaleLabel}
onPress={() =>
showOptions({
title: t("home.settings.appearance.text_size"),
options: typographyScaleOptions,
onSelect: (value) =>
updateSettings({ tvTypographyScale: value }),
})
}
/>
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
@@ -358,6 +410,18 @@ export default function SettingsTV() {
value={settings.showHomeBackdrop}
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_hero_carousel")}
value={settings.showTVHeroCarousel}
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_series_poster_on_episode")}
value={settings.showSeriesPosterOnEpisode}
onToggle={(value) =>
updateSettings({ showSeriesPosterOnEpisode: value })
}
/>
{/* User Section */}
<TVSectionHeader

View File

@@ -15,14 +15,29 @@ import { useAtom } from "jotai";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
import { FlatList, Platform, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import {
getItemNavigation,
TouchableItemRouter,
} from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import useRouter from "@/hooks/useAppRouter";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
@@ -36,6 +51,10 @@ import {
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -44,11 +63,15 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const router = useRouter();
const { showOptions } = useTVOptionModal();
const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP,
);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -56,7 +79,7 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection } = useQuery({
const { data: collection, isLoading: isCollectionLoading } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
@@ -71,6 +94,46 @@ const page: React.FC = () => {
staleTime: 60 * 1000,
});
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
queryKey: ["filters", "Genres", "tvGenreFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvYearOptions } = useQuery({
queryKey: ["filters", "Years", "tvYearFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
const { data: tvTagOptions } = useQuery({
queryKey: ["filters", "Tags", "tvTagFilter", collectionId],
queryFn: async () => {
if (!api) return [];
const response = await getFilterApi(api).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
},
enabled: Platform.isTV && !!api && !!user?.Id && !!collectionId,
});
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
setSortOrder([SortOrderOption.Ascending]);
@@ -87,6 +150,18 @@ const page: React.FC = () => {
setSortBy([sortByOption]);
}, [navigation, collection]);
// Calculate columns for TV grid
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
return orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5;
}, [screenWidth, orientation]);
const fetchItems = useCallback(
async ({
pageParam,
@@ -98,7 +173,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 18,
limit: Platform.isTV ? 36 : 18,
startIndex: pageParam,
// Set one ordering at a time. As collections do not work with correctly with multiple.
sortBy: [sortBy[0]],
@@ -123,6 +198,7 @@ const page: React.FC = () => {
api,
user?.Id,
collection,
collectionId,
selectedGenres,
selectedYears,
selectedTags,
@@ -131,39 +207,40 @@ const page: React.FC = () => {
],
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: [
"collection-items",
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
useInfiniteQuery({
queryKey: [
"collection-items",
collectionId,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0,
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
}
return undefined;
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
@@ -195,7 +272,6 @@ const page: React.FC = () => {
}}
>
<ItemPoster item={item} />
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
@@ -203,9 +279,38 @@ const page: React.FC = () => {
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(home)");
router.push(navTarget as any);
};
const _insets = useSafeAreaInsets();
return (
<View
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
)}
{item.Type !== "Movie" &&
item.Type !== "Series" &&
item.Type !== "Episode" && <MoviePoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
</View>
);
},
[router],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
@@ -372,48 +477,315 @@ const page: React.FC = () => {
],
);
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedGenres.length === 0,
},
...(tvGenreOptions || []).map((genre) => ({
label: genre,
value: genre,
selected: selectedGenres.includes(genre),
})),
],
[tvGenreOptions, selectedGenres, t],
);
const tvYearFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedYears.length === 0,
},
...(tvYearOptions || []).map((year) => ({
label: String(year),
value: String(year),
selected: selectedYears.includes(String(year)),
})),
],
[tvYearOptions, selectedYears, t],
);
const tvTagFilterOptions = useMemo(
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
selected: selectedTags.length === 0,
},
...(tvTagOptions || []).map((tag) => ({
label: tag,
value: tag,
selected: selectedTags.includes(tag),
})),
],
[tvTagOptions, selectedTags, t],
);
const tvSortByOptions = useMemo(
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortBy[0] === option.key,
})),
[sortBy],
);
const tvSortOrderOptions = useMemo(
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
selected: sortOrder[0] === option.key,
})),
[sortOrder],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
// TV filter bar state
const hasActiveFilters =
selectedGenres.length > 0 ||
selectedYears.length > 0 ||
selectedTags.length > 0;
const resetAllFilters = useCallback(() => {
setSelectedGenres([]);
setSelectedYears([]);
setSelectedTags([]);
}, [setSelectedGenres, setSelectedYears, setSelectedTags]);
if (isLoading || isCollectionLoading) {
return (
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
}
if (!collection) return null;
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
// Mobile return
if (!Platform.isTV) {
return (
<FlashList
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
extraData={[
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
);
}
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
)}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
</View>
{/* Grid */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
/>
</View>
);
};

View File

@@ -62,6 +62,7 @@ const page: React.FC = () => {
});
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id),
});
@@ -117,7 +118,8 @@ const page: React.FC = () => {
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: isOffline ? Infinity : 60,
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id),
});

View File

@@ -1,4 +1,3 @@
import { Ionicons } from "@expo/vector-icons";
import type {
BaseItemDto,
BaseItemDtoQueryResult,
@@ -12,23 +11,13 @@ import {
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
FlatList,
Platform,
Pressable,
ScrollView,
useWindowDimensions,
View,
@@ -48,9 +37,15 @@ import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import {
TVFilterButton,
TVFocusablePoster,
TVItemCardText,
} from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
@@ -74,280 +69,11 @@ import {
yearFilterAtom,
} from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
{item.ProductionYear}
</Text>
</View>
);
// TV Filter Types and Components
type TVFilterModalType =
| "genre"
| "year"
| "tags"
| "sortBy"
| "sortOrder"
| "filterBy"
| null;
interface TVFilterOption<T> {
label: string;
value: T;
selected: boolean;
}
const TVFilterOptionCard: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View style={{ position: "absolute", top: 8, right: 8 }}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
const TVFilterButton: React.FC<{
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
hasActiveFilter?: boolean;
}> = ({
label,
value,
onPress,
hasTVPreferredFocus,
disabled,
hasActiveFilter,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.04);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={{ transform: [{ scale }] }}>
<View
style={{
backgroundColor: focused
? "#fff"
: hasActiveFilter
? "rgba(255, 255, 255, 0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
borderWidth: hasActiveFilter && !focused ? 1 : 0,
borderColor: "rgba(255, 255, 255, 0.4)",
}}
>
{label ? (
<Text style={{ fontSize: 14, color: focused ? "#444" : "#bbb" }}>
{label}
</Text>
) : null}
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
const TVFilterSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVFilterOption<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
// Track initial focus index - only set once when modal opens
const initialFocusIndexRef = useRef<number | null>(null);
// Calculate initial focus index only once when visible becomes true
if (visible && initialFocusIndexRef.current === null) {
const idx = options.findIndex((o) => o.selected);
initialFocusIndexRef.current = idx >= 0 ? idx : 0;
}
// Reset when modal closes
if (!visible) {
initialFocusIndexRef.current = null;
return null;
}
const initialFocusIndex = initialFocusIndexRef.current ?? 0;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View style={{ paddingVertical: 24 }}>
<Text
style={{
fontSize: 20,
fontWeight: "600",
color: "#fff",
paddingHorizontal: 48,
marginBottom: 16,
}}
>
{title}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVFilterOptionCard
key={String(option.value)}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialFocusIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const _TV_SCALE_PADDING = 20;
const Page = () => {
const searchParams = useLocalSearchParams() as {
@@ -358,6 +84,7 @@ const Page = () => {
};
const { libraryId } = searchParams;
const typography = useScaledTVTypography();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { width: screenWidth } = useWindowDimensions();
@@ -380,13 +107,7 @@ const Page = () => {
const { t } = useTranslation();
const router = useRouter();
// TV Filter modal state
const [openFilterModal, setOpenFilterModal] =
useState<TVFilterModalType>(null);
const isFilterModalOpen = openFilterModal !== null;
const isFiltersDisabled = isFilterModalOpen;
const { showOptions } = useTVOptionModal();
// TV Filter queries
const { data: tvGenreOptions } = useQuery({
@@ -511,12 +232,8 @@ const Page = () => {
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
// TV uses flexWrap, so nrOfCols is just for mobile
return 1;
}
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
@@ -682,7 +399,7 @@ const Page = () => {
);
const renderTVItem = useCallback(
({ item }: { item: BaseItemDto }) => {
(item: BaseItemDto) => {
const handlePress = () => {
const navTarget = getItemNavigation(item, "(libraries)");
router.push(navTarget as any);
@@ -690,13 +407,12 @@ const Page = () => {
return (
<View
key={item.Id}
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
<TVFocusablePoster onPress={handlePress} disabled={isFilterModalOpen}>
<TVFocusablePoster onPress={handlePress}>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
@@ -709,7 +425,7 @@ const Page = () => {
</View>
);
},
[router, isFilterModalOpen],
[router],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
@@ -912,7 +628,7 @@ const Page = () => {
// TV Filter options - with "All" option for clearable filters
const tvGenreFilterOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -928,7 +644,7 @@ const Page = () => {
);
const tvYearFilterOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -944,7 +660,7 @@ const Page = () => {
);
const tvTagFilterOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -960,7 +676,7 @@ const Page = () => {
);
const tvSortByOptions = useMemo(
(): TVFilterOption<SortByOption>[] =>
(): TVOptionItem<SortByOption>[] =>
sortOptions.map((option) => ({
label: option.value,
value: option.key,
@@ -970,7 +686,7 @@ const Page = () => {
);
const tvSortOrderOptions = useMemo(
(): TVFilterOption<SortOrderOption>[] =>
(): TVOptionItem<SortOrderOption>[] =>
sortOrderOptions.map((option) => ({
label: option.value,
value: option.key,
@@ -980,7 +696,7 @@ const Page = () => {
);
const tvFilterByOptions = useMemo(
(): TVFilterOption<string>[] => [
(): TVOptionItem<string>[] => [
{
label: t("library.filters.all"),
value: "__all__",
@@ -995,56 +711,88 @@ const Page = () => {
[filterBy, generalFilters, t],
);
// TV Filter handlers
const handleGenreSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
[selectedGenres, setSelectedGenres],
);
// TV Filter handlers using navigation-based modal
const handleShowGenreFilter = useCallback(() => {
showOptions({
title: t("library.filters.genres"),
options: tvGenreFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedGenres([]);
} else if (selectedGenres.includes(value)) {
setSelectedGenres(selectedGenres.filter((g) => g !== value));
} else {
setSelectedGenres([...selectedGenres, value]);
}
},
});
}, [showOptions, t, tvGenreFilterOptions, selectedGenres, setSelectedGenres]);
const handleYearSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
[selectedYears, setSelectedYears],
);
const handleShowYearFilter = useCallback(() => {
showOptions({
title: t("library.filters.years"),
options: tvYearFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedYears([]);
} else if (selectedYears.includes(value)) {
setSelectedYears(selectedYears.filter((y) => y !== value));
} else {
setSelectedYears([...selectedYears, value]);
}
},
});
}, [showOptions, t, tvYearFilterOptions, selectedYears, setSelectedYears]);
const handleTagSelect = useCallback(
(value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((t) => t !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
[selectedTags, setSelectedTags],
);
const handleShowTagFilter = useCallback(() => {
showOptions({
title: t("library.filters.tags"),
options: tvTagFilterOptions,
onSelect: (value: string) => {
if (value === "__all__") {
setSelectedTags([]);
} else if (selectedTags.includes(value)) {
setSelectedTags(selectedTags.filter((tag) => tag !== value));
} else {
setSelectedTags([...selectedTags, value]);
}
},
});
}, [showOptions, t, tvTagFilterOptions, selectedTags, setSelectedTags]);
const handleFilterBySelect = useCallback(
(value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
[setFilter, _setFilterBy],
);
const handleShowSortByFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_by"),
options: tvSortByOptions,
onSelect: (value: SortByOption) => {
setSortBy([value]);
},
});
}, [showOptions, t, tvSortByOptions, setSortBy]);
const handleShowSortOrderFilter = useCallback(() => {
showOptions({
title: t("library.filters.sort_order"),
options: tvSortOrderOptions,
onSelect: (value: SortOrderOption) => {
setSortOrder([value]);
},
});
}, [showOptions, t, tvSortOrderOptions, setSortOrder]);
const handleShowFilterByFilter = useCallback(() => {
showOptions({
title: t("library.filters.filter_by"),
options: tvFilterByOptions,
onSelect: (value: string) => {
if (value === "__all__") {
_setFilterBy([]);
} else {
setFilter([value as FilterByOption]);
}
},
});
}, [showOptions, t, tvFilterByOptions, setFilter, _setFilterBy]);
const insets = useSafeAreaInsets();
@@ -1097,185 +845,134 @@ const Page = () => {
);
}
// TV return with filter overlays - filter bar outside FlatList to fix focus boundary issues
// TV return with filter bar
return (
<View style={{ flex: 1 }}>
{/* Background content - disabled when modal is open */}
<View
style={{ flex: 1, opacity: isFilterModalOpen ? 0.3 : 1 }}
focusable={!isFilterModalOpen}
isTVSelectable={!isFilterModalOpen}
pointerEvents={isFilterModalOpen ? "none" : "auto"}
accessibilityElementsHidden={isFilterModalOpen}
importantForAccessibility={
isFilterModalOpen ? "no-hide-descendants" : "auto"
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
onScroll={({ nativeEvent }) => {
// Load more when near bottom
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isNearBottom =
layoutMeasurement.height + contentOffset.y >=
contentSize.height - 500;
if (isNearBottom && hasNextPage && !isFetching) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
{/* Filter bar */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "center",
paddingBottom: 24,
gap: 12,
}}
>
{/* Filter bar - using View instead of ScrollView to avoid focus conflicts */}
<View
style={{
flexDirection: "row",
flexWrap: "nowrap",
marginTop: insets.top + 100,
paddingBottom: 8,
paddingHorizontal: TV_SCALE_PADDING,
gap: 12,
}}
>
{hasActiveFilters && (
<TVFilterButton
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
disabled={isFiltersDisabled}
hasActiveFilter
/>
)}
{hasActiveFilters && (
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("genre")}
hasTVPreferredFocus={!hasActiveFilters}
disabled={isFiltersDisabled}
hasActiveFilter={selectedGenres.length > 0}
label=''
value={t("library.filters.reset")}
onPress={resetAllFilters}
hasActiveFilter
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("year")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("tags")}
disabled={isFiltersDisabled}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={() => setOpenFilterModal("sortBy")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={() => setOpenFilterModal("sortOrder")}
disabled={isFiltersDisabled}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={() => setOpenFilterModal("filterBy")}
disabled={isFiltersDisabled}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* Grid - using FlatList instead of FlashList to fix focus issues */}
<FlatList
key={`${orientation}-${nrOfCols}`}
ListEmptyComponent={
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
)}
<TVFilterButton
label={t("library.filters.genres")}
value={
selectedGenres.length > 0
? `${selectedGenres.length} selected`
: t("library.filters.all")
}
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderTVItem}
extraData={[orientation, nrOfCols, isFilterModalOpen]}
keyExtractor={keyExtractor}
numColumns={nrOfCols}
removeClippedSubviews={false}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={1}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: TV_SCALE_PADDING,
paddingRight: TV_SCALE_PADDING,
paddingTop: 20,
}}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
/>
)}
onPress={handleShowGenreFilter}
hasTVPreferredFocus={!hasActiveFilters}
hasActiveFilter={selectedGenres.length > 0}
/>
<TVFilterButton
label={t("library.filters.years")}
value={
selectedYears.length > 0
? `${selectedYears.length} selected`
: t("library.filters.all")
}
onPress={handleShowYearFilter}
hasActiveFilter={selectedYears.length > 0}
/>
<TVFilterButton
label={t("library.filters.tags")}
value={
selectedTags.length > 0
? `${selectedTags.length} selected`
: t("library.filters.all")
}
onPress={handleShowTagFilter}
hasActiveFilter={selectedTags.length > 0}
/>
<TVFilterButton
label={t("library.filters.sort_by")}
value={sortOptions.find((o) => o.key === sortBy[0])?.value || ""}
onPress={handleShowSortByFilter}
/>
<TVFilterButton
label={t("library.filters.sort_order")}
value={
sortOrderOptions.find((o) => o.key === sortOrder[0])?.value || ""
}
onPress={handleShowSortOrderFilter}
/>
<TVFilterButton
label={t("library.filters.filter_by")}
value={
filterBy.length > 0
? generalFilters.find((o) => o.key === filterBy[0])?.value || ""
: t("library.filters.all")
}
onPress={handleShowFilterByFilter}
hasActiveFilter={filterBy.length > 0}
/>
</View>
{/* TV Filter Overlays */}
<TVFilterSelector
visible={openFilterModal === "genre"}
title={t("library.filters.genres")}
options={tvGenreFilterOptions}
onSelect={handleGenreSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "year"}
title={t("library.filters.years")}
options={tvYearFilterOptions}
onSelect={handleYearSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "tags"}
title={t("library.filters.tags")}
options={tvTagFilterOptions}
onSelect={handleTagSelect}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortBy"}
title={t("library.filters.sort_by")}
options={tvSortByOptions}
onSelect={(value) => setSortBy([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "sortOrder"}
title={t("library.filters.sort_order")}
options={tvSortOrderOptions}
onSelect={(value) => setSortOrder([value])}
onClose={() => setOpenFilterModal(null)}
/>
<TVFilterSelector
visible={openFilterModal === "filterBy"}
title={t("library.filters.filter_by")}
options={tvFilterByOptions}
onSelect={handleFilterBySelect}
onClose={() => setOpenFilterModal(null)}
/>
</View>
{/* Grid with flexWrap */}
{flatData.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_results")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{flatData.map((item) => renderTVItem(item))}
</View>
)}
{/* Loading indicator */}
{isFetching && (
<View style={{ paddingVertical: 20 }}>
<Loader />
</View>
)}
</ScrollView>
);
};

View File

@@ -10,6 +10,7 @@ import {
Alert,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
useWindowDimensions,
View,
@@ -28,6 +29,7 @@ import MoviePoster, {
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation";
import {
@@ -41,21 +43,36 @@ import {
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { userAtom } from "@/providers/JellyfinProvider";
const TV_ITEM_GAP = 16;
const TV_SCALE_PADDING = 20;
const TV_ITEM_GAP = 20;
const TV_HORIZONTAL_PADDING = 60;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => (
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => (
<View style={{ marginTop: 12 }}>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
<Text
style={{
fontSize: typography.callout - 2,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</View>
);
export default function WatchlistDetailScreen() {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const router = useRouter();
const navigation = useNavigation();
@@ -70,14 +87,8 @@ export default function WatchlistDetailScreen() {
: undefined;
const nrOfCols = useMemo(() => {
if (Platform.isTV) {
// Calculate columns based on TV poster width + gap
const itemWidth = TV_POSTER_WIDTH + TV_ITEM_GAP;
return Math.max(
1,
Math.floor((screenWidth - TV_SCALE_PADDING * 2) / itemWidth),
);
}
// TV uses flexWrap, so nrOfCols is just for mobile
if (Platform.isTV) return 1;
if (screenWidth < 300) return 2;
if (screenWidth < 500) return 3;
if (screenWidth < 800) return 5;
@@ -185,7 +196,7 @@ export default function WatchlistDetailScreen() {
);
const renderTVItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => {
(item: BaseItemDto, index: number) => {
const handlePress = () => {
const navigation = getItemNavigation(item, "(watchlists)");
router.push(navigation as any);
@@ -193,9 +204,8 @@ export default function WatchlistDetailScreen() {
return (
<View
key={item.Id}
style={{
marginRight: TV_ITEM_GAP,
marginBottom: TV_ITEM_GAP,
width: TV_POSTER_WIDTH,
}}
>
@@ -208,11 +218,11 @@ export default function WatchlistDetailScreen() {
<SeriesPoster item={item} />
)}
</TVFocusablePoster>
<TVItemCardText item={item} />
<TVItemCardText item={item} typography={typography} />
</View>
);
},
[router],
[router, typography],
);
const renderItem = useCallback(
@@ -328,6 +338,120 @@ export default function WatchlistDetailScreen() {
);
}
// TV layout with ScrollView + flexWrap
if (Platform.isTV) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 100,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + TV_HORIZONTAL_PADDING,
}}
>
{/* Header */}
<View
style={{
alignItems: "center",
marginBottom: 32,
paddingBottom: 24,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
}}
>
{watchlist.description && (
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
marginBottom: 16,
textAlign: "center",
}}
>
{watchlist.description}
</Text>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 24,
}}
>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons name='film-outline' size={20} color='#9ca3af' />
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{items?.length ?? 0}{" "}
{(items?.length ?? 0) === 1
? t("watchlists.item")
: t("watchlists.items")}
</Text>
</View>
<View
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<Ionicons
name={
watchlist.isPublic ? "globe-outline" : "lock-closed-outline"
}
size={20}
color='#9ca3af'
/>
<Text style={{ fontSize: typography.callout, color: "#9CA3AF" }}>
{watchlist.isPublic
? t("watchlists.public")
: t("watchlists.private")}
</Text>
</View>
{!isOwner && (
<Text style={{ fontSize: typography.callout, color: "#737373" }}>
{t("watchlists.by_owner")}
</Text>
)}
</View>
</View>
{/* Grid with flexWrap */}
{!items || items.length === 0 ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingTop: 100,
}}
>
<Ionicons name='film-outline' size={48} color='#4b5563' />
<Text
style={{
fontSize: typography.body,
color: "#9CA3AF",
textAlign: "center",
marginTop: 16,
}}
>
{t("watchlists.empty_watchlist")}
</Text>
</View>
) : (
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: TV_ITEM_GAP,
}}
>
{items.map((item, index) => renderTVItem(item, index))}
</View>
)}
</ScrollView>
);
}
// Mobile layout with FlashList
return (
<FlashList
key={orientation}
@@ -340,14 +464,13 @@ export default function WatchlistDetailScreen() {
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingBottom: 24,
paddingLeft: Platform.isTV ? TV_SCALE_PADDING : insets.left,
paddingRight: Platform.isTV ? TV_SCALE_PADDING : insets.right,
paddingTop: Platform.isTV ? TV_SCALE_PADDING : 0,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
renderItem={Platform.isTV ? renderTVItem : renderItem}
renderItem={renderItem}
ItemSeparatorComponent={() => (
<View
style={{

View File

@@ -17,7 +17,7 @@ import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRo
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
@@ -30,6 +30,7 @@ import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/
import { store } from "@/utils/store";
export default function TVRequestModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
@@ -336,8 +337,12 @@ export default function TVRequestModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.advanced")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{isDataLoaded && isReady ? (
<ScrollView
@@ -390,7 +395,12 @@ export default function TVRequestModalPage() {
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
<Text
style={[
styles.buttonText,
{ fontSize: typography.callout },
]}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
@@ -451,13 +461,11 @@ const styles = StyleSheet.create({
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
@@ -482,7 +490,6 @@ const styles = StyleSheet.create({
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},

View File

@@ -16,7 +16,7 @@ import {
import { Text } from "@/components/common/Text";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
@@ -162,6 +162,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
};
export default function TVSeasonSelectModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeasonSelectModalAtom);
const { t } = useTranslation();
@@ -305,8 +306,12 @@ export default function TVSeasonSelectModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.select_seasons")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
<Text style={[styles.heading, { fontSize: typography.heading }]}>
{t("jellyseerr.select_seasons")}
</Text>
<Text style={[styles.subtitle, { fontSize: typography.callout }]}>
{modalState.title}
</Text>
{/* Season cards horizontal scroll */}
<ScrollView
@@ -343,7 +348,9 @@ export default function TVSeasonSelectModalPage() {
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
<Text
style={[styles.buttonText, { fontSize: typography.callout }]}
>
{t("jellyseerr.request_selected")}
{selectedSeasons.size > 0 && ` (${selectedSeasons.size})`}
</Text>
@@ -377,13 +384,11 @@ const styles = StyleSheet.create({
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
@@ -413,7 +418,6 @@ const styles = StyleSheet.create({
flex: 1,
},
seasonTitle: {
fontSize: TVTypography.callout,
fontWeight: "600",
marginBottom: 4,
},
@@ -436,7 +440,6 @@ const styles = StyleSheet.create({
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},

View File

@@ -12,12 +12,13 @@ import {
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton, TVOptionCard } from "@/components/tv";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
import { store } from "@/utils/store";
export default function TVSeriesSeasonModalPage() {
const typography = useScaledTVTypography();
const router = useRouter();
const modalState = useAtomValue(tvSeriesSeasonModalAtom);
const { t } = useTranslation();
@@ -103,7 +104,9 @@ export default function TVSeriesSeasonModalPage() {
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{t("item_card.select_season")}</Text>
<Text style={[styles.title, { fontSize: typography.callout }]}>
{t("item_card.select_season")}
</Text>
{isReady && (
<ScrollView
@@ -164,7 +167,6 @@ const styles = StyleSheet.create({
overflow: "visible",
},
title: {
fontSize: TVTypography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,

110
bun.lock
View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "streamyfin",
@@ -77,7 +78,6 @@
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-responsive-sizes": "^2.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.18.0",
"react-native-svg": "15.12.1",
@@ -562,8 +562,6 @@
"@react-native/normalize-colors": ["@react-native/normalize-colors@0.81.5", "", {}, "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g=="],
"@react-native/virtualized-lists": ["@react-native/virtualized-lists@0.79.7", "", { "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "*", "react-native": "*" }, "optionalPeers": ["@types/react"] }, "sha512-CPJ995n1WIyi7KeLj+/aeFCe6MWQrRRXfMvBnc7XP4noSa4WEJfH8Zcvl/iWYVxrQdIaInadoiYLakeSflz5jg=="],
"@react-navigation/bottom-tabs": ["@react-navigation/bottom-tabs@7.8.4", "", { "dependencies": { "@react-navigation/elements": "^2.8.1", "color": "^4.2.3", "sf-symbols-typescript": "^2.1.0" }, "peerDependencies": { "@react-navigation/native": "^7.1.19", "react": ">= 18.2.0", "react-native": "*", "react-native-safe-area-context": ">= 4.0.0", "react-native-screens": ">= 4.0.0" } }, "sha512-Ie+7EgUxfZmVXm4RCiJ96oaiwJVFgVE8NJoeUKLLcYEB/99wKbhuKPJNtbkpR99PHfhq64SE7476BpcP4xOFhw=="],
"@react-navigation/core": ["@react-navigation/core@7.13.0", "", { "dependencies": { "@react-navigation/routers": "^7.5.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-Fc/SO23HnlGnkou/z8JQUzwEMvhxuUhr4rdPTIZp/c8q1atq3k632Nfh8fEiGtk+MP1wtIvXdN2a5hBIWpLq3g=="],
@@ -804,10 +802,6 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caller-callsite": ["caller-callsite@2.0.0", "", { "dependencies": { "callsites": "^2.0.0" } }, "sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ=="],
"caller-path": ["caller-path@2.0.0", "", { "dependencies": { "caller-callsite": "^2.0.0" } }, "sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
@@ -1246,8 +1240,6 @@
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-directory": ["is-directory@0.3.1", "", {}, "sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -1326,8 +1318,6 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-better-errors": ["json-parse-better-errors@1.0.2", "", {}, "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -1696,8 +1686,6 @@
"react-native-reanimated-carousel": ["react-native-reanimated-carousel@4.0.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-native": ">=0.70.3", "react-native-gesture-handler": ">=2.9.0", "react-native-reanimated": ">=3.0.0" } }, "sha512-YZXlvZNghR5shFcI9hTA7h7bEhh97pfUSLZvLBAshpbkuYwJDKmQXejO/199T6hqGq0wCRwR0CWf2P4Vs6A4Fw=="],
"react-native-responsive-sizes": ["react-native-responsive-sizes@2.1.0", "", { "dependencies": { "react-native": "^0.79.2" } }, "sha512-uxWi0IDj8CBGRh6KJyQ2RagWmLTWPWF5sDnVpM4jt/khwhEdaUeGa/q9rHcVHbb4o+oo1Zei9P3zIwbFc1UGcw=="],
"react-native-safe-area-context": ["react-native-safe-area-context@5.6.2", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg=="],
"react-native-screens": ["react-native-screens@4.18.0", "", { "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ=="],
@@ -2292,8 +2280,6 @@
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"caller-callsite/callsites": ["callsites@2.0.0", "", {}, "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ=="],
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -2446,8 +2432,6 @@
"react-native-reanimated/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"react-native-responsive-sizes/react-native": ["react-native@0.79.7", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.79.7", "@react-native/codegen": "0.79.7", "@react-native/community-cli-plugin": "0.79.7", "@react-native/gradle-plugin": "0.79.7", "@react-native/js-polyfills": "0.79.7", "@react-native/normalize-colors": "0.79.7", "@react-native/virtualized-lists": "0.79.7", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.25.1", "base64-js": "^1.5.1", "chalk": "^4.0.0", "commander": "^12.0.0", "event-target-shim": "^5.0.1", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.82.0", "metro-source-map": "^0.82.0", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.1", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.25.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-7B2FJt/P+qulrkjWNttofiQjpZ5czSnL00kr6kQ9GpiykF/agX6Z2GVX6e5ggpQq2jqtyLvRtHIiUnKPYM77+w=="],
"react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="],
"react-native-web/memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
@@ -2994,30 +2978,6 @@
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"react-native-responsive-sizes/react-native/@react-native/assets-registry": ["@react-native/assets-registry@0.79.7", "", {}, "sha512-YeOXq8H5JZQbeIcAtHxmboDt02QG8ej8Z4SFVNh5UjaSb/0X1/v5/DhwNb4dfpIsQ5lFy75jeoSmUVp8qEKu9g=="],
"react-native-responsive-sizes/react-native/@react-native/codegen": ["@react-native/codegen@0.79.7", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/parser": "^7.25.3", "glob": "^7.1.1", "hermes-parser": "0.25.1", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "yargs": "^17.6.2" } }, "sha512-uOjsqpLccl0+8iHPBmrkFrWwK0ctW28M83Ln2z43HRNubkxk5Nxd3DoyphFPL/BwTG79Ixu+BqpCS7b9mtizpw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin": ["@react-native/community-cli-plugin@0.79.7", "", { "dependencies": { "@react-native/dev-middleware": "0.79.7", "chalk": "^4.0.0", "debug": "^2.2.0", "invariant": "^2.2.4", "metro": "^0.82.0", "metro-config": "^0.82.0", "metro-core": "^0.82.0", "semver": "^7.1.3" }, "peerDependencies": { "@react-native-community/cli": "*" }, "optionalPeers": ["@react-native-community/cli"] }, "sha512-UQADqWfnKfEGMIyOa1zI8TMAOOLDdQ3h2FTCG8bp+MFGLAaJowaa+4GGb71A26fbg06/qnGy/Kr0Mv41IFGZnQ=="],
"react-native-responsive-sizes/react-native/@react-native/gradle-plugin": ["@react-native/gradle-plugin@0.79.7", "", {}, "sha512-vQqVthSs2EGqzV4KI0uFr/B4hUVXhVM86ekYL8iZCXzO6bewZa7lEUNGieijY0jc0a/mBJ6KZDzMtcUoS5vFRA=="],
"react-native-responsive-sizes/react-native/@react-native/js-polyfills": ["@react-native/js-polyfills@0.79.7", "", {}, "sha512-Djgvfz6AOa8ZEWyv+KA/UnP+ZruM+clCauFTR6NeRyD8YELvXGt+6A231SwpNdRkM7aTDMv0cM0NUbAMEPy+1A=="],
"react-native-responsive-sizes/react-native/@react-native/normalize-colors": ["@react-native/normalize-colors@0.79.7", "", {}, "sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ=="],
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.25.1", "", { "dependencies": { "hermes-parser": "0.25.1" } }, "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ=="],
"react-native-responsive-sizes/react-native/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
"react-native-responsive-sizes/react-native/metro-runtime": ["metro-runtime@0.82.5", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-rQZDoCUf7k4Broyw3Ixxlq5ieIPiR1ULONdpcYpbJQ6yQ5GGEyYjtkztGD+OhHlw81LCR2SUAoPvtTus2WDK5g=="],
"react-native-responsive-sizes/react-native/metro-source-map": ["metro-source-map@0.82.5", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.82.5", "nullthrows": "^1.1.1", "ob1": "0.82.5", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-wH+awTOQJVkbhn2SKyaw+0cd+RVSCZ3sHVgyqJFQXIee/yLs3dZqKjjeKKhhVeudgjXo7aE/vSu/zVfcQEcUfw=="],
"react-native-responsive-sizes/react-native/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="],
"react-native-responsive-sizes/react-native/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -3202,26 +3162,6 @@
"metro/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware": ["@react-native/dev-middleware@0.79.7", "", { "dependencies": { "@isaacs/ttlcache": "^1.4.1", "@react-native/debugger-frontend": "0.79.7", "chrome-launcher": "^0.15.2", "chromium-edge-launcher": "^0.2.0", "connect": "^3.6.5", "debug": "^2.2.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", "ws": "^6.2.3" } }, "sha512-KHGPa7xwnKKWrzMnV1cHc8J56co4tFevmRvbjEbUCqkGS0s/l8ZxAGMR222/6YxZV3Eg1J3ywKQ8nHzTsTz5jw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro": ["metro@0.82.5", "", { "dependencies": { "@babel/code-frame": "^7.24.7", "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "@babel/types": "^7.25.2", "accepts": "^1.3.7", "chalk": "^4.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.29.1", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-config": "0.82.5", "metro-core": "0.82.5", "metro-file-map": "0.82.5", "metro-resolver": "0.82.5", "metro-runtime": "0.82.5", "metro-source-map": "0.82.5", "metro-symbolicate": "0.82.5", "metro-transform-plugins": "0.82.5", "metro-transform-worker": "0.82.5", "mime-types": "^2.1.27", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-8oAXxL7do8QckID/WZEKaIFuQJFUTLzfVcC48ghkHhNK2RGuQq8Xvf4AVd+TUA0SZtX0q8TGNXZ/eba1ckeGCg=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config": ["metro-config@0.82.5", "", { "dependencies": { "connect": "^3.6.5", "cosmiconfig": "^5.0.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", "metro": "0.82.5", "metro-cache": "0.82.5", "metro-core": "0.82.5", "metro-runtime": "0.82.5" } }, "sha512-/r83VqE55l0WsBf8IhNmc/3z71y2zIPe5kRSuqA5tY/SL/ULzlHUJEMd1szztd0G45JozLwjvrhAzhDPJ/Qo/g=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core": ["metro-core@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", "metro-resolver": "0.82.5" } }, "sha512-OJL18VbSw2RgtBm1f2P3J5kb892LCVJqMvslXxuxjAPex8OH7Eb8RBfgEo7VZSjgb/LOf4jhC4UFk5l5tAOHHA=="],
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"react-native-responsive-sizes/react-native/metro-source-map/@babel/traverse--for-generate-function-map": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"react-native-responsive-sizes/react-native/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
"react-native-responsive-sizes/react-native/metro-source-map/ob1": ["ob1@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-QyQQ6e66f+Ut/qUVjEce0E/wux5nAGLXYZDn1jr15JWstHsCH3l6VVrg8NKDptW9NEiBXKOJeGF/ydxeSDF3IQ=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"sucrase/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -3258,42 +3198,6 @@
"logkitty/yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"react-native-responsive-sizes/react-native/@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/@react-native/dev-middleware/@react-native/debugger-frontend": ["@react-native/debugger-frontend@0.79.7", "", {}, "sha512-91JVlhR6hDuJXcWTpCwcdEPlUQf+TckNG8BYfR4UkUOaZ87XahJv4EyWBeyfd8lwB/mh6nDJqbR6UiXwt5kbog=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-babel-transformer": ["metro-babel-transformer@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.29.1", "nullthrows": "^1.1.1" } }, "sha512-W/scFDnwJXSccJYnOFdGiYr9srhbHPdxX9TvvACOFsIXdLilh3XuxQl/wXW6jEJfgIb0jTvoTlwwrqvuwymr6Q=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-cache-key": ["metro-cache-key@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-qpVmPbDJuRLrT4kcGlUouyqLGssJnbTllVtvIgXfR7ZuzMKf0mGS+8WzcqzNK8+kCyakombQWR0uDd8qhWGJcA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-file-map": ["metro-file-map@0.82.5", "", { "dependencies": { "debug": "^4.4.0", "fb-watchman": "^2.0.0", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", "nullthrows": "^1.1.1", "walker": "^1.0.7" } }, "sha512-vpMDxkGIB+MTN8Af5hvSAanc6zXQipsAUO+XUx3PCQieKUfLwdoa8qaZ1WAQYRpaU+CJ8vhBcxtzzo3d9IsCIQ=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-symbolicate": ["metro-symbolicate@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.82.5", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-1u+07gzrvYDJ/oNXuOG1EXSvXZka/0JSW1q2EYBWerVKMOhvv9JzDGyzmuV7hHbF2Hg3T3S2uiM36sLz1qKsiw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-plugins": ["metro-transform-plugins@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/template": "^7.25.0", "@babel/traverse": "^7.25.3", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" } }, "sha512-57Bqf3rgq9nPqLrT2d9kf/2WVieTFqsQ6qWHpEng5naIUtc/Iiw9+0bfLLWSAw0GH40iJ4yMjFcFJDtNSYynMA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker": ["metro-transform-worker@0.82.5", "", { "dependencies": { "@babel/core": "^7.25.2", "@babel/generator": "^7.25.0", "@babel/parser": "^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "metro": "0.82.5", "metro-babel-transformer": "0.82.5", "metro-cache": "0.82.5", "metro-cache-key": "0.82.5", "metro-minify-terser": "0.82.5", "metro-source-map": "0.82.5", "metro-transform-plugins": "0.82.5", "nullthrows": "^1.1.1" } }, "sha512-mx0grhAX7xe+XUQH6qoHHlWedI8fhSpDGsfga7CpkO9Lk9W+aPitNtJWNGrW8PfjKEWbT9Uz9O50dkI8bJqigw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig": ["cosmiconfig@5.2.1", "", { "dependencies": { "import-fresh": "^2.0.0", "is-directory": "^0.3.1", "js-yaml": "^3.13.1", "parse-json": "^4.0.0" } }, "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/metro-cache": ["metro-cache@0.82.5", "", { "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", "metro-core": "0.82.5" } }, "sha512-AwHV9607xZpedu1NQcjUkua8v7HfOTKfftl6Vc9OGr/jbpiJX6Gpy8E/V9jo/U9UuVYX2PqSUcVNZmu+LTm71Q=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-core/metro-resolver": ["metro-resolver@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-kFowLnWACt3bEsuVsaRNgwplT8U7kETnaFHaZePlARz4Fg8tZtmRDUmjaD68CGAwc0rwdwNCkWizLYpnyVcs2g=="],
"react-native-responsive-sizes/react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"@expo/cli/ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
@@ -3306,18 +3210,6 @@
"logkitty/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh": ["import-fresh@2.0.0", "", { "dependencies": { "caller-path": "^2.0.0", "resolve-from": "^3.0.0" } }, "sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro/metro-transform-worker/metro-minify-terser": ["metro-minify-terser@0.82.5", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" } }, "sha512-v6Nx7A4We6PqPu/ta1oGTqJ4Usz0P7c+3XNeBxW9kp8zayS3lHUKR0sY0wsCHInxZlNAEICx791x+uXytFUuwg=="],
"logkitty/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/import-fresh/resolve-from": ["resolve-from@3.0.0", "", {}, "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw=="],
"react-native-responsive-sizes/react-native/@react-native/community-cli-plugin/metro-config/cosmiconfig/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
}
}

View File

@@ -1,7 +1,7 @@
import { BlurView } from "expo-blur";
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface Props extends ViewProps {
@@ -16,6 +16,8 @@ export const Badge: React.FC<Props> = ({
variant = "purple",
...props
}) => {
const typography = useScaledTVTypography();
const content = (
<View style={styles.content}>
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
@@ -69,7 +71,7 @@ export const Badge: React.FC<Props> = ({
{iconLeft && <View style={{ marginRight: 8 }}>{iconLeft}</View>}
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#E5E7EB",
}}
>

View File

@@ -14,7 +14,6 @@ import {
TouchableOpacity,
View,
} from "react-native";
import { fontSize, size } from "react-native-responsive-sizes";
import { useHaptic } from "@/hooks/useHaptic";
import { Loader } from "./Loader";
@@ -141,15 +140,11 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
}}
>
<View
className={`rounded-2xl items-center justify-center
className={`rounded-2xl py-5 items-center justify-center
${colorClasses}
${className}`}
style={{ paddingVertical: size(50) }}
>
<Text
className={`${textColorClass} font-bold`}
style={{ fontSize: fontSize(10) }}
>
<Text className={`${textColorClass} text-xl font-bold`}>
{children}
</Text>
</View>

View File

@@ -5,11 +5,15 @@ import { useAtomValue } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator";
export const TV_LANDSCAPE_WIDTH = 340;
export const TV_LANDSCAPE_WIDTH = 400;
type ContinueWatchingPosterProps = {
item: BaseItemDto;
@@ -32,34 +36,57 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return;
}
if (item.Type === "Episode" && useEpisodePoster) {
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Episode") {
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ParentThumbImageTag}`;
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Movie") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.Type === "Program") {
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=600&quality=80&tag=${item.ImageTags?.Thumb}`;
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item, useEpisodePoster]);
const progress = useMemo(() => {
if (item.Type === "Program") {
if (!item.StartDate || !item.EndDate) {
return 0;
}
const startDate = new Date(item.StartDate);
const endDate = new Date(item.EndDate);
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
if (total <= 0) {
return 0;
}
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
const isWatched = item.UserData?.Played === true;
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (!url) {
return (
<View
@@ -72,6 +99,39 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
);
}
if (useGlass) {
return (
<View style={{ position: "relative" }}>
<GlassPosterView
imageUrl={url}
aspectRatio={16 / 9}
cornerRadius={24}
progress={progress}
showWatchedIndicator={isWatched}
isFocused={false}
width={TV_LANDSCAPE_WIDTH}
style={{ width: TV_LANDSCAPE_WIDTH }}
/>
{showPlayButton && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='play-circle' size={56} color='white' />
</View>
)}
</View>
);
}
// Fallback for older tvOS versions
return (
<View
style={{

View File

@@ -10,7 +10,7 @@ import {
type ViewProps,
} from "react-native";
import { GlassEffectView } from "react-native-glass-effect-view";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { Text } from "./common/Text";
interface TagProps {
@@ -25,6 +25,9 @@ export const Tag: React.FC<
textStyle?: StyleProp<TextStyle>;
} & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
// Hook must be called at the top level, before any conditional returns
const typography = useScaledTVTypography();
if (Platform.OS === "ios" && !Platform.isTV) {
return (
<View>
@@ -60,7 +63,7 @@ export const Tag: React.FC<
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text style={{ fontSize: TVTypography.callout, color: "#E5E7EB" }}>
<Text style={{ fontSize: typography.callout, color: "#E5E7EB" }}>
{text}
</Text>
</View>

View File

@@ -4,8 +4,8 @@ import type {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQueryClient } from "@tanstack/react-query";
import { getTvShowsApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { useAtom } from "jotai";
@@ -17,12 +17,14 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
import { Dimensions, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { GenreTags } from "@/components/GenreTags";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import {
TVBackdrop,
TVButton,
@@ -37,7 +39,7 @@ import {
TVTechnicalDetails,
} from "@/components/tv";
import type { Track } from "@/components/video-player/controls/types";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
@@ -69,8 +71,9 @@ interface ItemContentTVProps {
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {
const typography = useScaledTVTypography();
const [api] = useAtom(apiAtom);
const [_user] = useAtom(userAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -80,6 +83,31 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const _itemColors = useImageColorsReturn({ item });
// State for first episode card ref (used for focus guide)
const [_firstEpisodeRef, setFirstEpisodeRef] = useState<View | null>(null);
// Fetch season episodes for episodes
const { data: seasonEpisodes = [] } = useQuery({
queryKey: ["episodes", item?.SeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item?.SeriesId || !item?.SeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.SeriesId,
userId: user.Id,
seasonId: item.SeasonId,
enableUserData: true,
fields: ["MediaSources", "Overview"],
});
return res.data.Items || [];
},
enabled:
!!api &&
!!user?.Id &&
!!item?.SeriesId &&
!!item?.SeasonId &&
item?.Type === "Episode",
});
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
@@ -135,14 +163,13 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const { showSubtitleModal } = useTVSubtitleModal();
// State for first actor card ref (used for focus guide)
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
const [_firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
// State for last option button ref (used for upward focus guide from cast)
const [lastOptionButtonRef, setLastOptionButtonRef] = useState<View | null>(
null,
);
const [_lastOptionButtonRef, setLastOptionButtonRef] =
useState<View | null>(null);
// Get available audio tracks
const audioTracks = useMemo(() => {
@@ -386,6 +413,18 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
}, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Episode thumbnail URL - episode's own primary image (16:9 for episodes)
const episodeThumbnailUrl = useMemo(() => {
if (item?.Type !== "Episode" || !api) return null;
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item]);
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
const seriesThumbUrl = useMemo(() => {
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
}, [api, item]);
// Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => {
const hasSubtitleOption =
@@ -427,6 +466,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}
}, [router, item?.SeriesId, item?.ParentIndexNumber]);
const handleEpisodePress = useCallback(
(episode: BaseItemDto) => {
const navigation = getItemNavigation(episode, "(home)");
router.push(navigation as any);
},
[router],
);
if (!item || !selectedOptions) return null;
return (
@@ -456,36 +503,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
<ItemImage
variant='Primary'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
</View>
</View>
{/* Right side - Content */}
{/* Left side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo or Title */}
{logoUrl ? (
@@ -502,7 +520,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
) : (
<Text
style={{
fontSize: TVTypography.display,
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 20,
@@ -518,7 +536,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: TVTypography.title,
fontSize: typography.title,
color: "#FFFFFF",
fontWeight: "600",
}}
@@ -527,7 +545,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "white",
marginTop: 6,
}}
@@ -572,7 +590,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
@@ -605,7 +623,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
@@ -714,14 +732,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
)}
</View>
{/* Focus guide to direct navigation from options to cast list */}
{fullCast.length > 0 && firstActorCardRef && (
<TVFocusGuideView
destinations={[firstActorCardRef]}
style={{ height: 1, width: "100%" }}
/>
)}
{/* Progress bar (if partially watched) */}
{hasProgress && item.RunTimeTicks != null && (
<TVProgressBar
@@ -733,11 +743,112 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
/>
)}
</View>
{/* Right side - Poster */}
<View
style={{
width:
item.Type === "Episode"
? SCREEN_WIDTH * 0.35
: SCREEN_WIDTH * 0.22,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: item.Type === "Episode" ? 16 / 9 : 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
{item.Type === "Episode" ? (
<Image
source={{
uri:
settings.showSeriesPosterOnEpisode && seriesThumbUrl
? seriesThumbUrl
: episodeThumbnailUrl!,
}}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<ItemImage
variant='Primary'
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
)}
</View>
</View>
</View>
{/* Additional info section */}
<View style={{ marginTop: 40 }}>
{/* Cast & Crew (text version) */}
{/* Season Episodes - Episode only */}
{item.Type === "Episode" && seasonEpisodes.length > 1 && (
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,
}}
>
{t("item_card.more_from_this_season")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginHorizontal: -80, overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 80,
paddingVertical: 12,
gap: 24,
}}
>
{seasonEpisodes.map((episode, index) => (
<TVEpisodeCard
key={episode.Id}
episode={episode}
onPress={() => handleEpisodePress(episode)}
disabled={episode.Id === item.Id}
refSetter={index === 0 ? setFirstEpisodeRef : undefined}
/>
))}
</ScrollView>
</View>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
/>
)}
{/* Cast & Crew (text version - director, etc.) */}
<TVCastCrewText
director={director}
cast={cast}
@@ -751,26 +862,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
mediaStreams={selectedOptions.mediaSource.MediaStreams}
/>
)}
{/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */}
{showVisualCast && (
<TVCastSection
cast={fullCast}
apiBasePath={api?.basePath}
onActorPress={handleActorPress}
firstActorRefSetter={setFirstActorCardRef}
upwardFocusDestination={lastOptionButtonRef}
/>
)}
{/* From this Series - Episode only */}
<TVSeriesNavigation
item={item}
seriesImageUrl={seriesImageUrl}
seasonImageUrl={seasonImageUrl}
onSeriesPress={handleSeriesPress}
onSeasonPress={handleSeasonPress}
/>
</View>
</ScrollView>
</View>

View File

@@ -1,41 +1,28 @@
import React from "react";
import { Dimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const { width: SCREEN_WIDTH } = Dimensions.get("window");
export const ItemContentSkeletonTV: React.FC = () => {
const insets = useSafeAreaInsets();
return (
<View
style={{
flex: 1,
flexDirection: "row",
paddingTop: 180,
paddingHorizontal: 160,
paddingTop: insets.top + 140,
paddingHorizontal: insets.left + 80,
}}
>
{/* Left side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
{/* Left side - Content placeholders */}
<View style={{ flex: 1 }}>
{/* Logo placeholder */}
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
{/* Right side - Content placeholders */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Logo/Title placeholder */}
<View
style={{
height: 80,
width: "60%",
height: 150,
width: "80%",
backgroundColor: "#1a1a1a",
borderRadius: 8,
marginBottom: 24,
@@ -155,6 +142,22 @@ export const ItemContentSkeletonTV: React.FC = () => {
}}
/>
</View>
{/* Right side - Poster placeholder */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginLeft: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
backgroundColor: "#1a1a1a",
}}
/>
</View>
</View>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -4,7 +4,7 @@ export function Text(props: TextProps) {
if (Platform.isTV)
return (
<RNText
allowFontScaling={true}
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
/>

View File

@@ -11,7 +11,7 @@ import heart from "@/assets/icons/heart.fill.png";
import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { Colors } from "@/constants/Colors";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HORIZONTAL_PADDING = 60;
@@ -28,6 +28,7 @@ type FavoriteTypes =
type EmptyState = Record<FavoriteTypes, boolean>;
export const Favorites = () => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -148,7 +149,7 @@ export const Favorites = () => {
/>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
@@ -160,7 +161,7 @@ export const Favorites = () => {
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
}}
>

View File

@@ -598,11 +598,14 @@ const HomeMobile = () => {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
const streamystatsIndex =
sections.length - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||

View File

@@ -30,8 +30,9 @@ import { Text } from "@/components/common/Text";
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList.tv";
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists.tv";
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations.tv";
import { TVHeroCarousel } from "@/components/home/TVHeroCarousel";
import { Loader } from "@/components/Loader";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
@@ -41,8 +42,8 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
// Reduced gap since sections have internal padding for scale animations
const SECTION_GAP = 10;
// Generous gap between sections for Apple TV+ aesthetic
const SECTION_GAP = 24;
type InfiniteScrollingCollectionListSection = {
type: "InfiniteScrollingCollectionList";
@@ -61,6 +62,7 @@ type Section = InfiniteScrollingCollectionListSection;
const BACKDROP_DEBOUNCE_MS = 300;
export const Home = () => {
const typography = useScaledTVTypography();
const _router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
@@ -201,6 +203,58 @@ export const Home = () => {
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
// Fetch hero items (Continue Watching + Next Up combined)
const { data: heroItems } = useQuery({
queryKey: ["home", "heroItems", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const [resumeResponse, nextUpResponse] = await Promise.all([
getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
fields: ["Overview"],
startIndex: 0,
limit: 10,
}),
getTvShowsApi(api).getNextUp({
userId: user.Id,
startIndex: 0,
limit: 10,
fields: ["Overview"],
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
}),
]);
const resumeItems = resumeResponse.data.Items || [];
const nextUpItems = nextUpResponse.data.Items || [];
// Combine, sort by recent activity, and dedupe
const combined = [...resumeItems, ...nextUpItems];
const sorted = combined.sort((a, b) => {
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
const seen = new Set<string>();
const deduped: BaseItemDto[] = [];
for (const item of sorted) {
if (!item.Id || seen.has(item.Id)) continue;
seen.add(item.Id);
deduped.push(item);
}
return deduped.slice(0, 8);
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
});
const userViews = useMemo(
@@ -526,7 +580,7 @@ export const Home = () => {
>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
@@ -538,7 +592,7 @@ export const Home = () => {
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
@@ -579,7 +633,7 @@ export const Home = () => {
>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
marginBottom: 8,
color: "#FFFFFF",
@@ -591,7 +645,7 @@ export const Home = () => {
style={{
textAlign: "center",
opacity: 0.7,
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
}}
>
@@ -607,84 +661,106 @@ export const Home = () => {
</View>
);
// Determine if hero should be shown (separate setting from backdrop)
const showHero =
heroItems && heroItems.length > 0 && settings.showTVHeroCarousel;
return (
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
{/* Dynamic backdrop with crossfade - only shown when hero is disabled */}
{!showHero && settings.showHomeBackdrop && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
)}
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingTop: showHero ? 0 : insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
{sections.map((section, index) => {
// Render Streamystats sections after Continue Watching and Next Up
// When merged, they appear after index 0; otherwise after index 1
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
? 0
: 1;
{/* Hero Carousel - Apple TV+ style featured content */}
{showHero && (
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
)}
<View
style={{
gap: SECTION_GAP,
paddingHorizontal: insets.left + HORIZONTAL_PADDING,
paddingTop: showHero ? SECTION_GAP : 0,
}}
>
{/* Skip first section (Continue Watching) when hero is shown since hero displays that content */}
{sections.slice(showHero ? 1 : 0).map((section, index) => {
// Render Streamystats sections after Recently Added sections
// For default sections: place after Recently Added, before Suggested Movies (if present)
// For custom sections: place at the very end
const hasSuggestedMovies =
!settings?.streamyStatsMovieRecommendations &&
!settings?.home?.sections;
// Adjust index calculation to account for sliced array when hero is shown
const displayedSectionsLength =
sections.length - (showHero ? 1 : 0);
const streamystatsIndex =
displayedSectionsLength - 1 - (hasSuggestedMovies ? 1 : 0);
const hasStreamystatsContent =
settings.streamyStatsMovieRecommendations ||
settings.streamyStatsSeriesRecommendations ||
@@ -723,7 +799,8 @@ export const Home = () => {
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
const isFirstSection = index === 0;
// First section only gets preferred focus if hero is not shown
const isFirstSection = index === 0 && !showHero;
return (
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList

View File

@@ -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>
);
};

View File

@@ -71,7 +71,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
},
initialPageParam: 0,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,

View File

@@ -20,7 +20,7 @@ import MoviePoster, {
TV_POSTER_WIDTH,
} from "@/components/posters/MoviePoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster, {
@@ -28,7 +28,7 @@ import ContinueWatchingPoster, {
} from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
const ITEM_GAP = 16;
const ITEM_GAP = 24;
// Extra padding to accommodate scale animation (1.05x) and glow shadow
const SCALE_PADDING = 20;
@@ -48,22 +48,27 @@ interface Props extends ViewProps {
parentId?: string;
}
type Typography = ReturnType<typeof useScaledTVTypography>;
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -77,13 +82,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -103,7 +108,8 @@ const TVSeeAllCard: React.FC<{
disabled?: boolean;
onFocus?: () => void;
onBlur?: () => void;
}> = ({ onPress, orientation, disabled, onFocus, onBlur }) => {
typography: Typography;
}> = ({ onPress, orientation, disabled, onFocus, onBlur, typography }) => {
const { t } = useTranslation();
const width =
orientation === "horizontal" ? TV_LANDSCAPE_WIDTH : TV_POSTER_WIDTH;
@@ -137,7 +143,7 @@ const TVSeeAllCard: React.FC<{
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#FFFFFF",
fontWeight: "600",
}}
@@ -165,6 +171,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
parentId,
...props
}) => {
const typography = useScaledTVTypography();
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
@@ -211,7 +218,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
},
initialPageParam: 0,
staleTime: 60 * 1000,
refetchOnMount: false,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
@@ -343,7 +350,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
>
{renderPoster()}
</TVFocusablePoster>
<TVItemCardText item={item} />
<TVItemCardText item={item} typography={typography} />
</View>
);
},
@@ -354,6 +361,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
handleItemPress,
handleItemFocus,
handleItemBlur,
typography,
],
);
@@ -365,11 +373,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
{/* Section Header */}
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginBottom: 20,
marginLeft: SCALE_PADDING,
letterSpacing: 0.5,
}}
>
{title}
@@ -379,7 +388,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
<Text
style={{
color: "#737373",
fontSize: TVTypography.callout,
fontSize: typography.callout,
marginLeft: SCALE_PADDING,
}}
>
@@ -420,7 +429,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: TVTypography.callout,
fontSize: typography.callout,
}}
numberOfLines={1}
>
@@ -477,6 +486,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
disabled={disabled}
onFocus={handleSeeAllFocus}
onBlur={handleItemBlur}
typography={typography}
/>
)}
</View>

View File

@@ -44,7 +44,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryKey: queryKey,
queryFn,
staleTime: 60 * 1000, // 1 minute
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled: enableLazyLoading ? isInView : true,

View File

@@ -80,7 +80,6 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -215,7 +214,6 @@ export const StreamystatsPromotedWatchlists: React.FC<
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -16,7 +16,7 @@ import MoviePoster, {
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -26,18 +26,23 @@ import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
type Typography = ReturnType<typeof useScaledTVTypography>;
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -60,6 +65,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
onItemFocus,
...props
}) => {
const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -108,8 +114,8 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
Boolean(settings?.streamyStatsServerUrl) &&
Boolean(api?.accessToken) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -142,11 +148,11 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
<TVItemCardText item={item} typography={typography} />
</View>
);
},
[handleItemPress, onItemFocus],
[handleItemPress, onItemFocus, typography],
);
if (!isLoading && (!items || items.length === 0)) return null;
@@ -155,11 +161,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginBottom: 20,
marginLeft: SCALE_PADDING,
letterSpacing: 0.5,
}}
>
{watchlist.name}
@@ -278,8 +285,8 @@ export const StreamystatsPromotedWatchlists: React.FC<
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});

View File

@@ -103,7 +103,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnMount: false,
refetchOnWindowFocus: false,
});
@@ -136,7 +135,6 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});

View File

@@ -16,7 +16,7 @@ import MoviePoster, {
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -26,6 +26,8 @@ import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystat
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
type Typography = ReturnType<typeof useScaledTVTypography>;
interface Props extends ViewProps {
title: string;
type: "Movie" | "Series";
@@ -34,18 +36,21 @@ interface Props extends ViewProps {
onItemFocus?: (item: BaseItemDto) => void;
}
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const TVItemCardText: React.FC<{
item: BaseItemDto;
typography: Typography;
}> = ({ item, typography }) => {
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
<Text
numberOfLines={1}
style={{ fontSize: TVTypography.callout, color: "#FFFFFF" }}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -64,6 +69,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
onItemFocus,
...props
}) => {
const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const { settings } = useSettings();
@@ -133,8 +139,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
Boolean(api?.accessToken) &&
Boolean(jellyfinServerId) &&
Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -166,8 +172,8 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
},
enabled:
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
staleTime: 5 * 60 * 1000,
refetchOnMount: false,
staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
refetchOnWindowFocus: false,
});
@@ -203,11 +209,11 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
{item.Type === "Movie" && <MoviePoster item={item} />}
{item.Type === "Series" && <SeriesPoster item={item} />}
</TVFocusablePoster>
<TVItemCardText item={item} />
<TVItemCardText item={item} typography={typography} />
</View>
);
},
[handleItemPress, onItemFocus],
[handleItemPress, onItemFocus, typography],
);
if (!streamyStatsEnabled) return null;
@@ -218,11 +224,12 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View style={{ overflow: "visible" }} {...props}>
<Text
style={{
fontSize: TVTypography.body,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginBottom: 20,
marginLeft: SCALE_PADDING,
letterSpacing: 0.5,
}}
>
{title}

View 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>
);
};

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
type DiscoverEndpoint,
@@ -33,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item,
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -130,7 +131,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
</View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
@@ -142,7 +143,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
{year && (
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -164,6 +165,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
slide,
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -232,7 +234,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,

View File

@@ -22,7 +22,7 @@ import { Loader } from "@/components/Loader";
import { JellyserrRatings } from "@/components/Ratings";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
@@ -68,6 +68,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
onPress,
refSetter,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -128,7 +129,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
</View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
textAlign: "center",
@@ -158,6 +159,7 @@ const TVCastCard: React.FC<TVCastCardProps> = ({
};
export const TVJellyseerrPage: React.FC = () => {
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
@@ -552,7 +554,7 @@ export const TVJellyseerrPage: React.FC = () => {
{/* Title */}
<Text
style={{
fontSize: TVTypography.display,
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginTop: 8,
@@ -566,7 +568,7 @@ export const TVJellyseerrPage: React.FC = () => {
{/* Year */}
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "rgba(255,255,255,0.7)",
marginBottom: 16,
}}
@@ -601,7 +603,7 @@ export const TVJellyseerrPage: React.FC = () => {
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
@@ -636,7 +638,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#000000",
}}
@@ -663,7 +665,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
@@ -698,7 +700,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -732,7 +734,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -757,7 +759,7 @@ export const TVJellyseerrPage: React.FC = () => {
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginLeft: 8,
}}
@@ -776,7 +778,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -794,7 +796,7 @@ export const TVJellyseerrPage: React.FC = () => {
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -813,7 +815,7 @@ export const TVJellyseerrPage: React.FC = () => {
<View style={{ marginTop: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,

View File

@@ -20,7 +20,7 @@ import {
import { Text } from "@/components/common/Text";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
@@ -51,6 +51,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
onClose,
onRequested,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
@@ -389,7 +390,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -399,7 +400,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
}}
@@ -473,7 +474,7 @@ export const TVRequestModal: React.FC<TVRequestModalProps> = ({
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}

View File

@@ -3,7 +3,7 @@ import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface TVRequestOptionRowProps {
label: string;
@@ -20,6 +20,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.02,
@@ -56,7 +57,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
}}
>
@@ -65,7 +66,7 @@ export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
fontWeight: "500",
}}

View File

@@ -2,7 +2,7 @@ import React from "react";
import { Animated, Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
interface ToggleItem {
id: number;
@@ -21,6 +21,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
onToggle,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.08,
@@ -57,7 +58,7 @@ const TVToggleChip: React.FC<TVToggleChipProps> = ({
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: item.selected || focused ? "600" : "400",
}}
@@ -82,13 +83,14 @@ export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
onToggle,
disabled = false,
}) => {
const typography = useScaledTVTypography();
if (items.length === 0) return null;
return (
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 10,
}}

View File

@@ -15,6 +15,7 @@ import { Animated, Easing, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -55,6 +56,7 @@ const TVLibraryRow: React.FC<{
}> = ({ library, isFirst, onPress }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(0.7)).current;
@@ -190,7 +192,7 @@ const TVLibraryRow: React.FC<{
<Text
numberOfLines={1}
style={{
fontSize: 32,
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
textShadowColor: "rgba(0,0,0,0.8)",
@@ -203,7 +205,7 @@ const TVLibraryRow: React.FC<{
{library.itemCount !== undefined && (
<Text
style={{
fontSize: 18,
fontSize: typography.body,
color: "rgba(255,255,255,0.7)",
marginTop: 4,
textShadowColor: "rgba(0,0,0,0.8)",
@@ -237,6 +239,7 @@ export const TVLibraries: React.FC = () => {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { data: userViews, isLoading: viewsLoading } = useQuery({
queryKey: ["user-views", user?.Id],
@@ -360,7 +363,7 @@ export const TVLibraries: React.FC = () => {
alignItems: "center",
}}
>
<Text style={{ fontSize: 20, color: "#737373" }}>
<Text style={{ fontSize: typography.body, color: "#737373" }}>
{t("library.no_libraries_found")}
</Text>
</View>

View File

@@ -12,6 +12,7 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -44,6 +45,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { t } = useTranslation();
const typography = useScaledTVTypography();
const url = useMemo(
() =>
@@ -148,7 +150,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
<Text
numberOfLines={1}
style={{
fontSize: 22,
fontSize: typography.body,
fontWeight: "600",
color: "#FFFFFF",
marginTop: 12,
@@ -160,7 +162,7 @@ export const TVLibraryCard: React.FC<Props> = ({ library }) => {
{itemsCount !== undefined && (
<Text
style={{
fontSize: 14,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 4,
}}

View File

@@ -6,7 +6,6 @@ import {
TextInput,
type TextInputProps,
} from "react-native";
import { fontSize, size } from "react-native-responsive-sizes";
interface TVInputProps extends TextInputProps {
label?: string;
@@ -59,8 +58,8 @@ export const TVInput: React.FC<TVInputProps> = ({
<Animated.View
style={{
transform: [{ scale }],
borderRadius: size(20),
borderWidth: size(6),
borderRadius: 10,
borderWidth: 3,
borderColor: isFocused ? "#FFFFFF" : "#333333",
}}
>
@@ -70,8 +69,8 @@ export const TVInput: React.FC<TVInputProps> = ({
allowFontScaling={false}
style={[
{
height: size(200),
fontSize: fontSize(12),
height: 68,
fontSize: 24,
color: "#FFFFFF",
},
style,

View File

@@ -13,7 +13,6 @@ import {
ScrollView,
View,
} from "react-native";
import { fontSize, size } from "react-native-responsive-sizes";
import { z } from "zod";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
@@ -67,7 +66,7 @@ const TVBackButton: React.FC<{
setIsFocused(false);
animateFocus(false);
}}
style={{ alignSelf: "flex-start", marginBottom: size(40) }}
style={{ alignSelf: "flex-start", marginBottom: 40 }}
disabled={disabled}
focusable={!disabled}
>
@@ -76,25 +75,26 @@ const TVBackButton: React.FC<{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
paddingHorizontal: size(12),
borderRadius: size(8),
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: isFocused
? "rgba(168, 85, 247, 0.2)"
: "transparent",
borderWidth: size(2),
borderWidth: 2,
borderColor: isFocused ? Colors.primary : "transparent",
}}
>
<Ionicons
name='chevron-back'
size={size(28)}
size={28}
color={isFocused ? "#FFFFFF" : Colors.primary}
/>
<Text
style={{
color: isFocused ? "#FFFFFF" : Colors.primary,
fontSize: fontSize(14),
marginLeft: size(4),
fontSize: 20,
marginLeft: 4,
}}
>
{label}
@@ -463,7 +463,7 @@ export const TVLogin: React.FC = () => {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: size(20),
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
@@ -471,7 +471,7 @@ export const TVLogin: React.FC = () => {
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: size(40),
paddingHorizontal: 60,
}}
>
{/* Back Button */}
@@ -484,10 +484,10 @@ export const TVLogin: React.FC = () => {
{/* Title */}
<Text
style={{
fontSize: fontSize(12),
fontSize: 48,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: size(8),
marginBottom: 8,
}}
>
{serverName ? (
@@ -501,18 +501,16 @@ export const TVLogin: React.FC = () => {
</Text>
<Text
style={{
fontSize: fontSize(14),
fontSize: 18,
color: "#9CA3AF",
marginBottom: size(40),
marginBottom: 40,
}}
>
{api.basePath}
</Text>
{/* Username Input - extra padding for focus scale */}
<View
style={{ marginBottom: size(24), paddingHorizontal: size(8) }}
>
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.username_placeholder")}
value={credentials.username}
@@ -529,9 +527,7 @@ export const TVLogin: React.FC = () => {
</View>
{/* Password Input */}
<View
style={{ marginBottom: size(32), paddingHorizontal: size(8) }}
>
<View style={{ marginBottom: 32, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("login.password_placeholder")}
value={credentials.password}
@@ -547,9 +543,7 @@ export const TVLogin: React.FC = () => {
</View>
{/* Save Account Toggle */}
<View
style={{ marginBottom: size(40), paddingHorizontal: size(8) }}
>
<View style={{ marginBottom: 40, paddingHorizontal: 8 }}>
<TVSaveAccountToggle
value={saveAccount}
onValueChange={setSaveAccount}
@@ -559,7 +553,7 @@ export const TVLogin: React.FC = () => {
</View>
{/* Login Button */}
<View style={{ marginBottom: size(16) }}>
<View style={{ marginBottom: 16 }}>
<Button
onPress={handleLogin}
loading={loading}
@@ -585,11 +579,9 @@ export const TVLogin: React.FC = () => {
style={{ flex: 1 }}
contentContainerStyle={{
flexGrow: 1,
// justifyContent: "center",
justifyContent: "center",
alignItems: "center",
justifyContent: "flex-start",
paddingTop: size(40),
paddingBottom: size(40),
paddingVertical: 60,
}}
showsVerticalScrollIndicator={false}
>
@@ -597,14 +589,14 @@ export const TVLogin: React.FC = () => {
style={{
width: "100%",
maxWidth: 800,
paddingHorizontal: size(60),
paddingHorizontal: 60,
}}
>
{/* Logo */}
<View style={{ alignItems: "center", marginBottom: size(5) }}>
<View style={{ alignItems: "center", marginBottom: 16 }}>
<Image
source={require("@/assets/images/icon-tvos.png")}
style={{ width: size(400), height: size(400) }}
style={{ width: 150, height: 150 }}
contentFit='contain'
/>
</View>
@@ -612,30 +604,28 @@ export const TVLogin: React.FC = () => {
{/* Title */}
<Text
style={{
fontSize: fontSize(24),
fontSize: 48,
fontWeight: "bold",
color: "#FFFFFF",
textAlign: "center",
marginBottom: size(8),
marginBottom: 8,
}}
>
Streamyfin
</Text>
<Text
style={{
fontSize: fontSize(10),
fontSize: 20,
color: "#9CA3AF",
textAlign: "center",
marginBottom: size(40),
marginBottom: 40,
}}
>
{t("server.enter_url_to_jellyfin_server")}
</Text>
{/* Server URL Input - extra padding for focus scale */}
<View
style={{ marginBottom: size(50), paddingHorizontal: size(300) }}
>
<View style={{ marginBottom: 24, paddingHorizontal: 8 }}>
<TVInput
placeholder={t("server.server_url_placeholder")}
value={serverURL}
@@ -650,9 +640,7 @@ export const TVLogin: React.FC = () => {
</View>
{/* Connect Button */}
<View
style={{ marginBottom: size(12), paddingHorizontal: size(270) }}
>
<View style={{ marginBottom: 24 }}>
<Button
onPress={() => handleConnect(serverURL)}
loading={loadingServerCheck}
@@ -663,7 +651,7 @@ export const TVLogin: React.FC = () => {
</View>
{/* Previous Servers */}
<View style={{ paddingHorizontal: size(300) }}>
<View style={{ paddingHorizontal: 8 }}>
<TVPreviousServersList
onServerSelect={(s) => handleConnect(s.address)}
onQuickLogin={handleQuickLoginWithSavedCredential}

View File

@@ -13,7 +13,6 @@ import {
View,
} from "react-native";
import { useMMKVString } from "react-native-mmkv";
import { fontSize, height, size, width } from "react-native-responsive-sizes";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import {
@@ -62,8 +61,8 @@ const TVServerActionCard: React.FC<{
<Animated.View
style={{
transform: [{ scale }],
width: width(25),
height: height(15),
width: 180,
height: 90,
backgroundColor: focused
? isDestructive
? "#ef4444"
@@ -71,16 +70,16 @@ const TVServerActionCard: React.FC<{
: isDestructive
? "rgba(239, 68, 68, 0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: size(20),
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: size(12),
gap: size(8),
paddingHorizontal: 12,
gap: 8,
}}
>
<Ionicons
name={icon}
size={size(56)}
size={28}
color={
focused
? isDestructive
@@ -93,7 +92,7 @@ const TVServerActionCard: React.FC<{
/>
<Text
style={{
fontSize: fontSize(12),
fontSize: 16,
color: focused
? isDestructive
? "#fff"
@@ -143,26 +142,26 @@ const TVServerActionSheet: React.FC<{
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: size(24),
borderTopRightRadius: size(24),
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: size(24),
paddingBottom: size(50),
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: fontSize(12),
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: size(12),
paddingHorizontal: size(48),
marginBottom: 8,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
@@ -176,9 +175,9 @@ const TVServerActionSheet: React.FC<{
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: size(48),
paddingVertical: size(10),
gap: size(12),
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
<TVServerActionCard
@@ -399,19 +398,19 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
if (!previousServers.length) return null;
return (
<View style={{ marginTop: size(100) }}>
<View style={{ marginTop: 32 }}>
<Text
style={{
fontSize: fontSize(12),
fontSize: 24,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: size(20),
marginBottom: 16,
}}
>
{t("server.previous_servers")}
</Text>
<View style={{ gap: size(12) }}>
<View style={{ gap: 12 }}>
{previousServers.map((server) => (
<TVServerCard
key={server.address}
@@ -438,39 +437,39 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: size(80),
padding: 80,
}}
>
<View
style={{
backgroundColor: "#1a1a1a",
borderRadius: size(24),
padding: size(40),
borderRadius: 24,
padding: 40,
width: "100%",
maxWidth: width(70),
maxWidth: 700,
}}
>
<Text
style={{
fontSize: fontSize(32),
fontSize: 32,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: size(8),
marginBottom: 8,
}}
>
{t("server.select_account")}
</Text>
<Text
style={{
fontSize: fontSize(18),
fontSize: 18,
color: "#9CA3AF",
marginBottom: size(32),
marginBottom: 32,
}}
>
{selectedServer?.name || selectedServer?.address}
</Text>
<View style={{ gap: size(12), marginBottom: size(24) }}>
<View style={{ gap: 12, marginBottom: 24 }}>
{selectedServer?.accounts.map((account, index) => (
<TVAccountCard
key={account.userId}
@@ -485,7 +484,7 @@ export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
))}
</View>
<View style={{ gap: size(12) }}>
<View style={{ gap: 12 }}>
<Button
onPress={() => {
setShowAccountsModal(false);

View File

@@ -7,7 +7,6 @@ import {
Pressable,
View,
} from "react-native";
import { fontSize, size } from "react-native-responsive-sizes";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
@@ -87,11 +86,11 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: size(6),
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: size(50),
paddingHorizontal: size(50),
paddingVertical: size(40),
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
@@ -100,7 +99,7 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: fontSize(12),
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
@@ -111,9 +110,9 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
{subtitle && (
<Text
style={{
fontSize: fontSize(10),
fontSize: 16,
color: "#9CA3AF",
marginTop: size(12),
marginTop: 4,
}}
numberOfLines={1}
>
@@ -122,27 +121,27 @@ export const TVServerCard: React.FC<TVServerCardProps> = ({
)}
</View>
<View style={{ marginLeft: size(16) }}>
<View style={{ marginLeft: 16 }}>
{isLoading ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : securityIcon ? (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name={securityIcon}
size={size(60)}
size={20}
color={Colors.primary}
style={{ marginRight: size(16) }}
style={{ marginRight: 8 }}
/>
<Ionicons
name='chevron-forward'
size={size(48)}
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
</View>
) : (
<Ionicons
name='chevron-forward'
size={size(48)}
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
)}

View File

@@ -4,10 +4,14 @@ import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_POSTER_WIDTH = 210;
export const TV_POSTER_WIDTH = 260;
type MoviePosterProps = {
item: BaseItemDto;
@@ -24,17 +28,37 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
return getPrimaryImageUrl({
api,
item,
width: 420, // 2x for quality on large screens
width: 520, // 2x for quality on large screens
});
}, [api, item]);
const progress = item.UserData?.PlayedPercentage || 0;
const isWatched = item.UserData?.Played === true;
const blurhash = useMemo(() => {
const key = item.ImageTags?.Primary as string;
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (useGlass) {
return (
<GlassPosterView
imageUrl={url ?? null}
aspectRatio={10 / 15}
cornerRadius={24}
progress={showProgress ? progress : 0}
showWatchedIndicator={isWatched}
isFocused={false}
width={TV_POSTER_WIDTH}
style={{ width: TV_POSTER_WIDTH }}
/>
);
}
// Fallback for older tvOS versions
return (
<View
style={{

View File

@@ -3,10 +3,14 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export const TV_POSTER_WIDTH = 210;
export const TV_POSTER_WIDTH = 260;
type SeriesPosterProps = {
item: BaseItemDto;
@@ -18,12 +22,12 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
const url = useMemo(() => {
if (item.Type === "Episode") {
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=630&quality=80&tag=${item.SeriesPrimaryImageTag}`;
return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=780&quality=80&tag=${item.SeriesPrimaryImageTag}`;
}
return getPrimaryImageUrl({
api,
item,
width: 420, // 2x for quality on large screens
width: 520, // 2x for quality on large screens
});
}, [api, item]);
@@ -32,6 +36,25 @@ const SeriesPoster: React.FC<SeriesPosterProps> = ({ item }) => {
return item.ImageBlurHashes?.Primary?.[key];
}, [item]);
// Use glass effect on tvOS 26+
const useGlass = isGlassEffectAvailable();
if (useGlass) {
return (
<GlassPosterView
imageUrl={url ?? null}
aspectRatio={10 / 15}
cornerRadius={24}
progress={0}
showWatchedIndicator={false}
isFocused={false}
width={TV_POSTER_WIDTH}
style={{ width: TV_POSTER_WIDTH }}
/>
);
}
// Fallback for older tvOS versions
return (
<View
style={{

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type {
@@ -27,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
onPress,
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -113,7 +114,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
</View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
@@ -125,7 +126,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
{year && (
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
@@ -147,6 +148,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
item,
onPress,
}) => {
const typography = useScaledTVTypography();
const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -202,7 +204,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
</View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600",
marginTop: 12,
@@ -230,13 +232,14 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
isFirstSection = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
@@ -281,13 +284,14 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
isFirstSection = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
@@ -332,13 +336,14 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
isFirstSection: _isFirstSection = false,
onItemPress,
}) => {
const typography = useScaledTVTypography();
if (!items || items.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVSearchBadgeProps {
label: string;
@@ -14,6 +15,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
onPress,
hasTVPreferredFocus = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
@@ -41,7 +43,7 @@ export const TVSearchBadge: React.FC<TVSearchBadgeProps> = ({
>
<Text
style={{
fontSize: 16,
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: focused ? "600" : "400",
}}

View File

@@ -7,6 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
@@ -26,6 +27,7 @@ const SCALE_PADDING = 20;
// Loading skeleton for TV
const TVLoadingSkeleton: React.FC = () => {
const typography = useScaledTVTypography();
const itemWidth = 210;
return (
<View style={{ overflow: "visible" }}>
@@ -71,7 +73,7 @@ const TVLoadingSkeleton: React.FC = () => {
color: "#262626",
backgroundColor: "#262626",
borderRadius: 6,
fontSize: 16,
fontSize: typography.callout,
}}
numberOfLines={1}
>
@@ -149,6 +151,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
onJellyseerrPersonPress,
discoverSliders,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
@@ -222,7 +225,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
}}
>
{/* Search Input */}
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING + 200 }}>
<Input
placeholder={t("search.search")}
value={search}
@@ -307,7 +310,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
<View style={{ alignItems: "center", paddingTop: 40 }}>
<Text
style={{
fontSize: 24,
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
@@ -315,7 +318,12 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
>
{t("search.no_results_found_for")}
</Text>
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
<Text
style={{
fontSize: typography.body,
color: "rgba(255,255,255,0.6)",
}}
>
"{debouncedSearch}"
</Text>
</View>

View File

@@ -11,22 +11,31 @@ import MoviePoster, {
} from "@/components/posters/MoviePoster.tv";
import SeriesPoster from "@/components/posters/SeriesPoster.tv";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVTypography } from "@/constants/TVTypography";
const ITEM_GAP = 16;
const SCALE_PADDING = 20;
// TV-specific ItemCardText with larger fonts
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const typography = useScaledTVTypography();
return (
<View style={{ marginTop: 12, flexDirection: "column" }}>
{item.Type === "Episode" ? (
<>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "}
@@ -36,53 +45,92 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
) : item.Type === "MusicArtist" ? (
<Text
numberOfLines={2}
style={{ fontSize: 16, color: "#FFFFFF", textAlign: "center" }}
style={{
fontSize: typography.callout,
color: "#FFFFFF",
textAlign: "center",
}}
>
{item.Name}
</Text>
) : item.Type === "MusicAlbum" ? (
<>
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.AlbumArtist || item.Artists?.join(", ")}
</Text>
</>
) : item.Type === "Audio" ? (
<>
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text
numberOfLines={1}
style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.Artists?.join(", ") || item.AlbumArtist}
</Text>
</>
) : item.Type === "Playlist" ? (
<>
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ChildCount} tracks
</Text>
</>
) : item.Type === "Person" ? (
<Text numberOfLines={2} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={2}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
) : (
<>
<Text numberOfLines={1} style={{ fontSize: 16, color: "#FFFFFF" }}>
<Text
numberOfLines={1}
style={{ fontSize: typography.callout, color: "#FFFFFF" }}
>
{item.Name}
</Text>
<Text style={{ fontSize: 14, color: "#9CA3AF", marginTop: 2 }}>
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{item.ProductionYear}
</Text>
</>
@@ -111,6 +159,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
imageUrlGetter,
...props
}) => {
const typography = useScaledTVTypography();
const flatListRef = useRef<FlatList<BaseItemDto>>(null);
const [focusedCount, setFocusedCount] = useState(0);
const prevFocusedCount = useRef(0);
@@ -311,11 +360,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
{/* Section Header */}
<Text
style={{
fontSize: 22,
fontWeight: "600",
fontSize: typography.heading,
fontWeight: "700",
color: "#FFFFFF",
marginBottom: 16,
marginBottom: 20,
marginLeft: SCALE_PADDING,
letterSpacing: 0.5,
}}
>
{title}

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
type SearchType = "Library" | "Discover";
@@ -20,6 +21,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
hasTVPreferredFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
@@ -61,7 +63,7 @@ const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
>
<Text
style={{
fontSize: 16,
fontSize: typography.callout,
color: getTextColor(),
fontWeight: isSelected || focused ? "600" : "400",
}}

View File

@@ -7,7 +7,7 @@ import { ProgressBar } from "@/components/common/ProgressBar";
import { Text } from "@/components/common/Text";
import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
@@ -33,6 +33,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
onBlur,
refSetter,
}) => {
const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const thumbnailUrl = useMemo(() => {
@@ -67,7 +68,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
}, [episode.ParentIndexNumber, episode.IndexNumber]);
return (
<View style={{ width: TV_EPISODE_WIDTH }}>
<View style={{ width: TV_EPISODE_WIDTH, opacity: disabled ? 0.5 : 1 }}>
<TVFocusablePoster
onPress={onPress}
hasTVPreferredFocus={hasTVPreferredFocus}
@@ -112,8 +113,8 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
{episodeLabel && (
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
fontSize: typography.callout,
color: "#FFFFFF",
fontWeight: "500",
}}
>
@@ -122,14 +123,10 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
)}
{duration && (
<>
<Text
style={{ color: "#6B7280", fontSize: TVTypography.callout }}
>
<Text style={{ color: "#FFFFFF", fontSize: typography.callout }}>
</Text>
<Text
style={{ fontSize: TVTypography.callout, color: "#9CA3AF" }}
>
<Text style={{ fontSize: typography.callout, color: "#FFFFFF" }}>
{duration}
</Text>
</>
@@ -138,7 +135,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
<Text
numberOfLines={2}
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#FFFFFF",
marginTop: 4,
fontWeight: "500",

View File

@@ -8,7 +8,7 @@ import { Dimensions, View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
@@ -19,6 +19,7 @@ interface TVSeriesHeaderProps {
}
export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
const typography = useScaledTVTypography();
const api = useAtomValue(apiAtom);
const logoUrl = useMemo(() => {
@@ -58,7 +59,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
) : (
<Text
style={{
fontSize: TVTypography.display,
fontSize: typography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
@@ -80,7 +81,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
}}
>
{yearString && (
<Text style={{ color: "white", fontSize: TVTypography.body }}>
<Text style={{ color: "white", fontSize: typography.body }}>
{yearString}
</Text>
)}
@@ -123,7 +124,7 @@ export const TVSeriesHeader: React.FC<TVSeriesHeaderProps> = ({ item }) => {
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#E5E7EB",
lineHeight: 32,
}}

View File

@@ -29,7 +29,7 @@ import { getItemNavigation } from "@/components/common/TouchableItemRouter";
import { seasonIndexAtom } from "@/components/series/SeasonPicker";
import { TVEpisodeCard } from "@/components/series/TVEpisodeCard";
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
import { useDownload } from "@/providers/DownloadProvider";
@@ -142,6 +142,7 @@ const TVSeasonButton: React.FC<{
onPress: () => void;
disabled?: boolean;
}> = ({ seasonName, onPress, disabled = false }) => {
const typography = useScaledTVTypography();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -190,7 +191,7 @@ const TVSeasonButton: React.FC<{
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "bold",
}}
@@ -213,6 +214,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
allEpisodes = [],
isLoading: _isLoading,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -293,6 +295,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
return response.data.Items || [];
},
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
@@ -345,7 +348,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
});
return res.data.Items || [];
},
staleTime: isOffline ? Infinity : 0,
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline ? 60 * 1000 : undefined,
enabled: isOffline
? !!item.Id && selectedSeasonNumber !== null
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
@@ -565,7 +569,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
/>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
fontWeight: "bold",
color: "#000000",
}}
@@ -589,10 +593,10 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
<View style={{ marginTop: 40, overflow: "visible" }}>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
marginBottom: 24,
marginLeft: SCALE_PADDING,
}}
>
@@ -644,7 +648,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
<Text
style={{
color: "#737373",
fontSize: TVTypography.callout,
fontSize: typography.callout,
marginLeft: SCALE_PADDING,
}}
>

View File

@@ -42,14 +42,6 @@ export const AppearanceSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")}
>

View File

@@ -158,14 +158,6 @@ export const OtherSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem title={t("home.settings.other.show_large_home_carousel")}>
<Switch
value={settings.showLargeHomeCarousel}
onValueChange={(value) =>
updateSettings({ showLargeHomeCarousel: value })
}
/>
</ListItem>
<ListItem
onPress={() => router.push("/settings/hide-libraries/page")}
title={t("home.settings.other.hide_libraries")}

View File

@@ -3,7 +3,7 @@ import { Image } from "expo-image";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVActorCardProps {
@@ -19,6 +19,7 @@ export interface TVActorCardProps {
export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
@@ -84,7 +85,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
@@ -98,7 +99,7 @@ export const TVActorCard = React.forwardRef<View, TVActorCardProps>(
{person.Role && (
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVCancelButtonProps {
@@ -16,6 +16,7 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
label = "Cancel",
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
@@ -48,7 +49,7 @@ export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
/>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500",
}}

View File

@@ -3,7 +3,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVCastCrewTextProps {
director?: BaseItemPerson | null;
@@ -14,6 +14,7 @@ export interface TVCastCrewTextProps {
export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
({ director, cast, hideCast = false }) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
if (!director && (!cast || cast.length === 0)) {
@@ -24,7 +25,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
@@ -37,7 +38,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
<View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
@@ -46,7 +47,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
>
{t("item_card.director")}
</Text>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{director.Name}
</Text>
</View>
@@ -55,7 +56,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
@@ -64,7 +65,7 @@ export const TVCastCrewText: React.FC<TVCastCrewTextProps> = React.memo(
>
{t("item_card.cast")}
</Text>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{cast.map((c) => c.Name).join(", ")}
</Text>
</View>

View File

@@ -3,7 +3,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, TVFocusGuideView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVActorCard } from "./TVActorCard";
export interface TVCastSectionProps {
@@ -24,6 +24,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
firstActorRefSetter,
upwardFocusDestination,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
if (cast.length === 0) {
@@ -34,7 +35,7 @@ export const TVCastSection: React.FC<TVCastSectionProps> = React.memo(
<View style={{ marginBottom: 40 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,

View File

@@ -4,12 +4,14 @@ import {
Pressable,
Animated as RNAnimated,
StyleSheet,
Text,
type View,
} from "react-native";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVControlButtonProps {
icon: keyof typeof Ionicons.glyphMap;
icon?: keyof typeof Ionicons.glyphMap;
text?: string;
onPress: () => void;
onLongPress?: () => void;
onPressOut?: () => void;
@@ -23,6 +25,7 @@ export interface TVControlButtonProps {
export const TVControlButton: FC<TVControlButtonProps> = ({
icon,
text,
onPress,
onLongPress,
onPressOut,
@@ -63,7 +66,11 @@ export const TVControlButton: FC<TVControlButtonProps> = ({
},
]}
>
<Ionicons name={icon} size={size} color='#fff' />
{text ? (
<Text style={[styles.text, { fontSize: size * 0.4 }]}>{text}</Text>
) : (
<Ionicons name={icon!} size={size} color='#fff' />
)}
</RNAnimated.View>
</Pressable>
);
@@ -78,4 +85,9 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
},
text: {
color: "#fff",
fontWeight: "600",
textAlign: "center",
},
});

View 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>
);
};

View File

@@ -70,8 +70,8 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
transform: [{ scale }],
shadowColor,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
shadowOpacity: focused ? 0.3 : 0,
shadowRadius: focused ? 12 : 0,
},
style,
]}

View 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>
);
};

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVLanguageCardProps {
@@ -15,6 +15,8 @@ export interface TVLanguageCardProps {
export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
const typography = useScaledTVTypography();
const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -72,26 +74,27 @@ export const TVLanguageCard = React.forwardRef<View, TVLanguageCardProps>(
},
);
const styles = StyleSheet.create({
languageCard: {
width: 120,
height: 60,
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
languageCardText: {
fontSize: TVTypography.callout,
fontWeight: "500",
},
languageCardCode: {
fontSize: TVTypography.callout,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
});
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
StyleSheet.create({
languageCard: {
width: 120,
height: 60,
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
languageCardText: {
fontSize: typography.callout,
fontWeight: "500",
},
languageCardCode: {
fontSize: typography.callout,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
});

View File

@@ -3,7 +3,7 @@ import React from "react";
import { View } from "react-native";
import { Badge } from "@/components/Badge";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVMetadataBadgesProps {
year?: number | null;
@@ -14,6 +14,8 @@ export interface TVMetadataBadgesProps {
export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
({ year, duration, officialRating, communityRating }) => {
const typography = useScaledTVTypography();
return (
<View
style={{
@@ -25,12 +27,12 @@ export const TVMetadataBadges: React.FC<TVMetadataBadgesProps> = React.memo(
}}
>
{year != null && (
<Text style={{ color: "white", fontSize: TVTypography.body }}>
<Text style={{ color: "white", fontSize: typography.body }}>
{year}
</Text>
)}
{duration && (
<Text style={{ color: "white", fontSize: TVTypography.body }}>
<Text style={{ color: "white", fontSize: typography.body }}>
{duration}
</Text>
)}

View File

@@ -1,7 +1,7 @@
import type { Api } from "@jellyfin/sdk";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
import { type FC, useEffect, useRef } from "react";
import { type FC, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Image, StyleSheet, View } from "react-native";
import Animated, {
@@ -13,7 +13,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
export interface TVNextEpisodeCountdownProps {
@@ -31,6 +31,7 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
isPlaying,
onFinish,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const progress = useSharedValue(0);
const onFinishRef = useRef(onFinish);
@@ -69,6 +70,8 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
width: `${progress.value * 100}%`,
}));
const styles = useMemo(() => createStyles(typography), [typography]);
if (!show) return null;
return (
@@ -105,57 +108,58 @@ export const TVNextEpisodeCountdown: FC<TVNextEpisodeCountdownProps> = ({
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
bottom: 180,
right: 80,
zIndex: 100,
},
blur: {
borderRadius: 16,
overflow: "hidden",
},
innerContainer: {
flexDirection: "row",
alignItems: "stretch",
},
thumbnail: {
width: 180,
backgroundColor: "rgba(0,0,0,0.3)",
},
content: {
padding: 16,
justifyContent: "center",
width: 280,
},
label: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
},
seriesName: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.7)",
marginBottom: 2,
},
episodeInfo: {
fontSize: TVTypography.body,
color: "#fff",
fontWeight: "600",
marginBottom: 12,
},
progressContainer: {
height: 4,
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 2,
overflow: "hidden",
},
progressBar: {
height: "100%",
backgroundColor: "#fff",
borderRadius: 2,
},
});
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
StyleSheet.create({
container: {
position: "absolute",
bottom: 180,
right: 80,
zIndex: 100,
},
blur: {
borderRadius: 16,
overflow: "hidden",
},
innerContainer: {
flexDirection: "row",
alignItems: "stretch",
},
thumbnail: {
width: 180,
backgroundColor: "rgba(0,0,0,0.3)",
},
content: {
padding: 16,
justifyContent: "center",
width: 280,
},
label: {
fontSize: typography.callout,
color: "rgba(255,255,255,0.5)",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
},
seriesName: {
fontSize: typography.callout,
color: "rgba(255,255,255,0.7)",
marginBottom: 2,
},
episodeInfo: {
fontSize: typography.body,
color: "#fff",
fontWeight: "600",
marginBottom: 12,
},
progressContainer: {
height: 4,
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 2,
overflow: "hidden",
},
progressBar: {
height: "100%",
backgroundColor: "#fff",
borderRadius: 2,
},
});

View File

@@ -2,7 +2,7 @@ import { BlurView } from "expo-blur";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVOptionButtonProps {
@@ -14,6 +14,7 @@ export interface TVOptionButtonProps {
export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
({ label, value, onPress, hasTVPreferredFocus }, ref) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
@@ -50,7 +51,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#444",
}}
>
@@ -58,7 +59,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#000",
fontWeight: "500",
}}
@@ -88,7 +89,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#bbb",
}}
>
@@ -96,7 +97,7 @@ export const TVOptionButton = React.forwardRef<View, TVOptionButtonProps>(
</Text>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#E5E7EB",
fontWeight: "500",
}}

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVOptionCardProps {
@@ -28,6 +28,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
},
ref,
) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -59,7 +60,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
@@ -71,7 +72,7 @@ export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
{sublabel && (
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 2,

View File

@@ -9,7 +9,7 @@ import {
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVCancelButton } from "./TVCancelButton";
import { TVOptionCard } from "./TVOptionCard";
@@ -41,6 +41,7 @@ export const TVOptionSelector = <T,>({
cardWidth = 160,
cardHeight = 75,
}: TVOptionSelectorProps<T>) => {
const typography = useScaledTVTypography();
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
@@ -91,6 +92,8 @@ export const TVOptionSelector = <T,>({
}
}, [isReady]);
const styles = useMemo(() => createStyles(typography), [typography]);
if (!visible) return null;
return (
@@ -151,50 +154,51 @@ export const TVOptionSelector = <T,>({
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: TVTypography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: typography.callout,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 20,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});

View File

@@ -3,7 +3,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
export interface TVSeriesNavigationProps {
@@ -16,6 +16,7 @@ export interface TVSeriesNavigationProps {
export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
({ item, seriesImageUrl, seasonImageUrl, onSeriesPress, onSeasonPress }) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
// Only show for episodes with a series
@@ -27,7 +28,7 @@ export const TVSeriesNavigation: React.FC<TVSeriesNavigationProps> = React.memo(
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 24,

View File

@@ -1,9 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Animated, Platform, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import {
GlassPosterView,
isGlassEffectAvailable,
} from "@/modules/glass-poster";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVSeriesSeasonCardProps {
@@ -21,9 +25,63 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
onPress,
hasTVPreferredFocus,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
// Check if glass effect is available (tvOS 26+)
const useGlass = Platform.OS === "ios" && isGlassEffectAvailable();
const renderPoster = () => {
if (useGlass) {
return (
<GlassPosterView
imageUrl={imageUrl}
aspectRatio={10 / 15}
cornerRadius={24}
progress={0}
showWatchedIndicator={false}
isFocused={focused}
width={210}
style={{ width: 210, marginBottom: 14 }}
/>
);
}
return (
<View
style={{
width: 210,
aspectRatio: 10 / 15,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 14,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film' size={56} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
);
};
return (
<Pressable
onPress={onPress}
@@ -38,45 +96,16 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 20 : 0,
shadowOpacity: useGlass ? 0 : focused ? 0.5 : 0,
shadowRadius: useGlass ? 0 : focused ? 20 : 0,
},
]}
>
<View
style={{
width: 210,
aspectRatio: 10 / 15,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 14,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film' size={56} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
{renderPoster()}
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
@@ -90,7 +119,7 @@ export const TVSeriesSeasonCard: React.FC<TVSeriesSeasonCardProps> = ({
{subtitle && (
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",

View File

@@ -8,7 +8,7 @@ import {
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
@@ -23,6 +23,8 @@ export const TVSubtitleResultCard = React.forwardRef<
View,
TVSubtitleResultCardProps
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const typography = useScaledTVTypography();
const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 });
@@ -197,72 +199,73 @@ export const TVSubtitleResultCard = React.forwardRef<
);
});
const styles = StyleSheet.create({
resultCard: {
width: 220,
minHeight: 120,
borderRadius: 14,
padding: 14,
borderWidth: 1,
},
providerBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
marginBottom: 8,
},
providerText: {
fontSize: TVTypography.callout,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
resultName: {
fontSize: TVTypography.callout,
fontWeight: "500",
marginBottom: 8,
lineHeight: 18,
},
resultMeta: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 8,
},
resultMetaText: {
fontSize: TVTypography.callout,
},
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
flagsContainer: {
flexDirection: "row",
gap: 6,
flexWrap: "wrap",
},
flag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
flagText: {
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
},
});
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
StyleSheet.create({
resultCard: {
width: 220,
minHeight: 120,
borderRadius: 14,
padding: 14,
borderWidth: 1,
},
providerBadge: {
alignSelf: "flex-start",
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
marginBottom: 8,
},
providerText: {
fontSize: typography.callout,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
resultName: {
fontSize: typography.callout,
fontWeight: "500",
marginBottom: 8,
lineHeight: 18,
},
resultMeta: {
flexDirection: "row",
alignItems: "center",
gap: 12,
marginBottom: 8,
},
resultMetaText: {
fontSize: typography.callout,
},
ratingContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
downloadCountContainer: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
flagsContainer: {
flexDirection: "row",
gap: 6,
flexWrap: "wrap",
},
flag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
flagText: {
fontSize: typography.callout,
fontWeight: "600",
color: "#fff",
},
downloadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.5)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
},
});

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTabButtonProps {
@@ -21,6 +21,7 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
switchOnFocus = false,
disabled = false,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
@@ -56,7 +57,7 @@ export const TVTabButton: React.FC<TVTabButtonProps> = ({
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: focused ? "#000" : "#fff",
fontWeight: focused || active ? "600" : "400",
}}

View File

@@ -3,7 +3,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVTechnicalDetailsProps {
mediaStreams: MediaStream[];
@@ -11,6 +11,7 @@ export interface TVTechnicalDetailsProps {
export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
({ mediaStreams }) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const videoStream = mediaStreams.find((s) => s.Type === "Video");
@@ -24,7 +25,7 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
<View style={{ marginBottom: 32 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontSize: typography.heading,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 20,
@@ -37,16 +38,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
<View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
Video
{t("common.video")}
</Text>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{videoStream.DisplayTitle ||
`${videoStream.Codec?.toUpperCase()} ${videoStream.Width}x${videoStream.Height}`}
</Text>
@@ -56,16 +57,16 @@ export const TVTechnicalDetails: React.FC<TVTechnicalDetailsProps> = React.memo(
<View>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#6B7280",
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 4,
}}
>
Audio
{t("common.audio")}
</Text>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{audioStream.DisplayTitle ||
`${audioStream.Codec?.toUpperCase()} ${audioStream.Channels}ch`}
</Text>

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTrackCardProps {
@@ -15,6 +15,8 @@ export interface TVTrackCardProps {
export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
const typography = useScaledTVTypography();
const styles = createStyles(typography);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -77,26 +79,27 @@ export const TVTrackCard = React.forwardRef<View, TVTrackCardProps>(
},
);
const styles = StyleSheet.create({
trackCard: {
width: 180,
height: 80,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
trackCardText: {
fontSize: TVTypography.callout,
textAlign: "center",
},
trackCardSublabel: {
fontSize: TVTypography.callout,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
});
const createStyles = (typography: ReturnType<typeof useScaledTVTypography>) =>
StyleSheet.create({
trackCard: {
width: 180,
height: 80,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
trackCardText: {
fontSize: typography.callout,
textAlign: "center",
},
trackCardSublabel: {
fontSize: typography.callout,
marginTop: 2,
},
checkmark: {
position: "absolute",
top: 8,
right: 8,
},
});

View File

@@ -25,8 +25,12 @@ export type { TVControlButtonProps } from "./TVControlButton";
export { TVControlButton } from "./TVControlButton";
export type { TVFavoriteButtonProps } from "./TVFavoriteButton";
export { TVFavoriteButton } from "./TVFavoriteButton";
export type { TVFilterButtonProps } from "./TVFilterButton";
export { TVFilterButton } from "./TVFilterButton";
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
export type { TVItemCardTextProps } from "./TVItemCardText";
export { TVItemCardText } from "./TVItemCardText";
export type { TVLanguageCardProps } from "./TVLanguageCard";
export { TVLanguageCard } from "./TVLanguageCard";
export type { TVMetadataBadgesProps } from "./TVMetadataBadges";

View File

@@ -2,7 +2,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVLogoutButtonProps {
@@ -15,6 +15,7 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
disabled,
}) => {
const { t } = useTranslation();
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -49,7 +50,7 @@ export const TVLogoutButton: React.FC<TVLogoutButtonProps> = ({
>
<Text
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
fontWeight: "bold",
color: "#FFFFFF",
}}

View File

@@ -1,24 +1,28 @@
import React from "react";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
export interface TVSectionHeaderProps {
title: string;
}
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => (
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#9CA3AF",
textTransform: "uppercase",
letterSpacing: 1,
marginTop: 32,
marginBottom: 16,
marginLeft: 8,
}}
>
{title}
</Text>
);
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => {
const typography = useScaledTVTypography();
return (
<Text
style={{
fontSize: typography.callout,
fontWeight: "600",
color: "#9CA3AF",
textTransform: "uppercase",
letterSpacing: 1,
marginTop: 32,
marginBottom: 16,
marginLeft: 8,
}}
>
{title}
</Text>
);
};

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsOptionButtonProps {
@@ -20,6 +20,7 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
isFirst,
disabled,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -49,13 +50,13 @@ export const TVSettingsOptionButton: React.FC<TVSettingsOptionButtonProps> = ({
},
]}
>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{label}
</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginRight: 12,
}}

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsRowProps {
@@ -22,6 +22,7 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
showChevron = true,
disabled,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -51,13 +52,13 @@ export const TVSettingsRow: React.FC<TVSettingsRowProps> = ({
},
]}
>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{label}
</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginRight: showChevron ? 12 : 0,
}}

View File

@@ -2,7 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsStepperProps {
@@ -24,6 +24,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
isFirst,
disabled,
}) => {
const typography = useScaledTVTypography();
const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
@@ -54,7 +55,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
focusable={!disabled}
>
<Animated.View style={labelAnim.animatedStyle}>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{label}
</Text>
</Animated.View>
@@ -89,7 +90,7 @@ export const TVSettingsStepper: React.FC<TVSettingsStepperProps> = ({
</Pressable>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#FFFFFF",
minWidth: 60,
textAlign: "center",

View File

@@ -1,7 +1,7 @@
import React, { useRef } from "react";
import { Animated, Pressable, TextInput } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsTextInputProps {
@@ -23,6 +23,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
secureTextEntry,
disabled,
}) => {
const typography = useScaledTVTypography();
const inputRef = useRef<TextInput>(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -56,7 +57,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
>
<Text
style={{
fontSize: TVTypography.callout,
fontSize: typography.callout,
color: "#9CA3AF",
marginBottom: 8,
}}
@@ -74,7 +75,7 @@ export const TVSettingsTextInput: React.FC<TVSettingsTextInputProps> = ({
autoCapitalize='none'
autoCorrect={false}
style={{
fontSize: TVTypography.body,
fontSize: typography.body,
color: "#FFFFFF",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
export interface TVSettingsToggleProps {
@@ -19,6 +19,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
isFirst,
disabled,
}) => {
const typography = useScaledTVTypography();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
@@ -48,7 +49,7 @@ export const TVSettingsToggle: React.FC<TVSettingsToggleProps> = ({
},
]}
>
<Text style={{ fontSize: TVTypography.body, color: "#FFFFFF" }}>
<Text style={{ fontSize: typography.body, color: "#FFFFFF" }}>
{label}
</Text>
<View

View File

@@ -13,7 +13,12 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TVFocusGuideView, View } from "react-native";
import {
StyleSheet,
TVFocusGuideView,
useWindowDimensions,
View,
} from "react-native";
import Animated, {
Easing,
type SharedValue,
@@ -26,8 +31,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
import { TVFocusableProgressBar } from "@/components/tv/TVFocusableProgressBar";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
@@ -82,6 +88,96 @@ interface Props {
const TV_SEEKBAR_HEIGHT = 14;
const TV_AUTO_HIDE_TIMEOUT = 5000;
// Trickplay bubble positioning constants
const TV_TRICKPLAY_SCALE = 2;
const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5;
const TV_TRICKPLAY_BUBBLE_WIDTH =
TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE;
const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE;
const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE;
const TV_TRICKPLAY_RIGHT_PADDING = 150;
const TV_TRICKPLAY_FADE_DURATION = 200;
interface TVTrickplayBubbleProps {
trickPlayUrl: {
x: number;
y: number;
url: string;
} | null;
trickplayInfo: {
aspectRatio?: number;
data: {
TileWidth?: number;
TileHeight?: number;
};
} | null;
time: {
hours: number;
minutes: number;
seconds: number;
};
progress: SharedValue<number>;
max: SharedValue<number>;
progressBarWidth: number;
visible: boolean;
}
const TVTrickplayBubblePositioned: FC<TVTrickplayBubbleProps> = ({
trickPlayUrl,
trickplayInfo,
time,
progress,
max,
progressBarWidth,
visible,
}) => {
const opacity = useSharedValue(0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: TV_TRICKPLAY_FADE_DURATION,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
const minX = TV_TRICKPLAY_INTERNAL_OFFSET;
const maxX =
progressBarWidth -
TV_TRICKPLAY_BUBBLE_WIDTH +
TV_TRICKPLAY_INTERNAL_OFFSET +
TV_TRICKPLAY_RIGHT_PADDING;
const animatedStyle = useAnimatedStyle(() => {
const progressPercent = max.value > 0 ? progress.value / max.value : 0;
const xPosition = Math.max(
minX,
Math.min(
maxX,
progressPercent * progressBarWidth -
TV_TRICKPLAY_BUBBLE_WIDTH / 2 +
TV_TRICKPLAY_CENTERING_OFFSET,
),
);
return {
transform: [{ translateX: xPosition }],
opacity: opacity.value,
};
});
return (
<Animated.View style={[styles.trickplayBubblePositioned, animatedStyle]}>
<TrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
imageScale={TV_TRICKPLAY_SCALE}
/>
</Animated.View>
);
};
export const Controls: FC<Props> = ({
item,
seek,
@@ -111,8 +207,17 @@ export const Controls: FC<Props> = ({
playMethod,
transcodeReasons,
}) => {
const typography = useScaledTVTypography();
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const { t } = useTranslation();
// Calculate progress bar width (matches the padding used in bottomInner)
const progressBarWidth = useMemo(() => {
const leftPadding = Math.max(insets.left, 48);
const rightPadding = Math.max(insets.right, 48);
return screenWidth - leftPadding - rightPadding;
}, [screenWidth, insets.left, insets.right]);
const api = useAtomValue(apiAtom);
const { settings } = useSettings();
const router = useRouter();
@@ -271,6 +376,15 @@ export const Controls: FC<Props> = ({
isSeeking,
});
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id!,
currentTime,
seek,
_play,
false,
api,
);
const getFinishTime = () => {
const now = new Date();
const finishTime = new Date(now.getTime() + remainingTime);
@@ -831,15 +945,17 @@ export const Controls: FC<Props> = ({
},
]}
>
{showSeekBubble && (
<View style={styles.trickplayBubbleContainer}>
<TrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={seekBubbleTime}
/>
</View>
)}
<View style={styles.trickplayBubbleContainer}>
<TVTrickplayBubblePositioned
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={seekBubbleTime}
progress={effectiveProgress}
max={max}
progressBarWidth={progressBarWidth}
visible={showSeekBubble}
/>
</View>
{/* Same padding as TVFocusableProgressBar for alignment */}
<View style={styles.minimalProgressWrapper}>
@@ -868,14 +984,16 @@ export const Controls: FC<Props> = ({
</View>
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
{formatTimeString(currentTime, "ms")}
</Text>
<View style={styles.timeRight}>
<Text style={styles.timeText}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text style={styles.endsAtText}>
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
@@ -901,12 +1019,18 @@ export const Controls: FC<Props> = ({
<View style={styles.metadataContainer}>
{item?.Type === "Episode" && (
<Text
style={styles.subtitleText}
style={[styles.subtitleText, { fontSize: typography.body }]}
>{`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`}</Text>
)}
<Text style={styles.titleText}>{item?.Name}</Text>
<Text style={[styles.titleText, { fontSize: typography.heading }]}>
{item?.Name}
</Text>
{item?.Type === "Movie" && (
<Text style={styles.subtitleText}>{item?.ProductionYear}</Text>
<Text
style={[styles.subtitleText, { fontSize: typography.body }]}
>
{item?.ProductionYear}
</Text>
)}
</View>
@@ -931,6 +1055,12 @@ export const Controls: FC<Props> = ({
disabled={false || !nextItem}
size={28}
/>
<TVControlButton
text='skip intro'
onPress={skipIntro}
disabled={!showSkipButton}
size={28}
/>
<View style={styles.controlButtonsSpacer} />
@@ -963,15 +1093,17 @@ export const Controls: FC<Props> = ({
)}
</View>
{showSeekBubble && (
<View style={styles.trickplayBubbleContainer}>
<TrickplayBubble
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={seekBubbleTime}
/>
</View>
)}
<View style={styles.trickplayBubbleContainer}>
<TVTrickplayBubblePositioned
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={seekBubbleTime}
progress={effectiveProgress}
max={max}
progressBarWidth={progressBarWidth}
visible={showSeekBubble}
/>
</View>
{/* Bidirectional focus guides - stacked together per docs */}
{/* Downward: play button → progress bar */}
@@ -1003,14 +1135,16 @@ export const Controls: FC<Props> = ({
</TVFocusGuideView>
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
{formatTimeString(currentTime, "ms")}
</Text>
<View style={styles.timeRight}>
<Text style={styles.timeText}>
<Text style={[styles.timeText, { fontSize: typography.body }]}>
-{formatTimeString(remainingTime, "ms")}
</Text>
<Text style={styles.endsAtText}>
<Text
style={[styles.endsAtText, { fontSize: typography.callout }]}
>
{t("player.ends_at")} {getFinishTime()}
</Text>
</View>
@@ -1044,11 +1178,9 @@ const styles = StyleSheet.create({
},
subtitleText: {
color: "rgba(255,255,255,0.6)",
fontSize: TVTypography.body,
},
titleText: {
color: "#fff",
fontSize: TVTypography.heading,
fontWeight: "bold",
},
controlButtonsRow: {
@@ -1063,12 +1195,15 @@ const styles = StyleSheet.create({
},
trickplayBubbleContainer: {
position: "absolute",
bottom: 120,
bottom: 170,
left: 0,
right: 0,
alignItems: "center",
zIndex: 20,
},
trickplayBubblePositioned: {
position: "absolute",
bottom: 0,
},
focusGuide: {
height: 1,
width: "100%",
@@ -1108,7 +1243,6 @@ const styles = StyleSheet.create({
},
timeText: {
color: "rgba(255,255,255,0.7)",
fontSize: TVTypography.body,
},
timeRight: {
flexDirection: "column",
@@ -1116,7 +1250,6 @@ const styles = StyleSheet.create({
},
endsAtText: {
color: "rgba(255,255,255,0.5)",
fontSize: TVTypography.callout,
marginTop: 2,
},
// Minimal seek bar styles

View File

@@ -15,7 +15,7 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TVTypography } from "@/constants/TVTypography";
import { useScaledTVTypography } from "@/constants/TVTypography";
import type { TechnicalInfo } from "@/modules/mpv-player";
import { useSettings } from "@/utils/atoms/settings";
import { HEADER_LAYOUT } from "./constants";
@@ -183,6 +183,7 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
currentSubtitleIndex,
currentAudioIndex,
}) => {
const typography = useScaledTVTypography();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [info, setInfo] = useState<TechnicalInfo | null>(null);
@@ -277,8 +278,15 @@ export const TechnicalInfoOverlay: FC<TechnicalInfoOverlayProps> = memo(
: HEADER_LAYOUT.CONTAINER_PADDING + 20,
};
const textStyle = Platform.isTV ? styles.infoTextTV : styles.infoText;
const reasonStyle = Platform.isTV ? styles.reasonTextTV : styles.reasonText;
const textStyle = Platform.isTV
? [
styles.infoTextTV,
{ fontSize: typography.body, lineHeight: typography.body * 1.5 },
]
: styles.infoText;
const reasonStyle = Platform.isTV
? [styles.reasonTextTV, { fontSize: typography.callout }]
: styles.reasonText;
const boxStyle = Platform.isTV ? styles.infoBoxTV : styles.infoBox;
return (
@@ -383,9 +391,7 @@ const styles = StyleSheet.create({
},
infoTextTV: {
color: "white",
fontSize: TVTypography.body,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
lineHeight: TVTypography.body * 1.5,
},
warningText: {
color: "#ff9800",
@@ -396,6 +402,5 @@ const styles = StyleSheet.create({
},
reasonTextTV: {
color: "#fbbf24",
fontSize: TVTypography.callout,
},
});

View File

@@ -4,6 +4,10 @@ import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { CONTROLS_CONSTANTS } from "./constants";
const BASE_IMAGE_SCALE = 1.4;
const BUBBLE_LEFT_OFFSET = 62;
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
interface TrickplayBubbleProps {
trickPlayUrl: {
x: number;
@@ -22,12 +26,21 @@ interface TrickplayBubbleProps {
minutes: number;
seconds: number;
};
/** Scale factor for the image (default 1). Does not affect timestamp text. */
imageScale?: number;
}
function formatTime(hours: number, minutes: number, seconds: number): string {
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
const prefix = hours > 0 ? `${hours}:` : "";
return `${prefix}${pad(minutes)}:${pad(seconds)}`;
}
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
trickPlayUrl,
trickplayInfo,
time,
imageScale = 1,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -36,16 +49,17 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!;
const finalScale = BASE_IMAGE_SCALE * imageScale;
return (
<View
style={{
position: "absolute",
left: -62,
left: -BUBBLE_LEFT_OFFSET * imageScale,
bottom: 0,
paddingTop: 30,
paddingBottom: 5,
width: tileWidth * 1.5,
width: tileWidth * BUBBLE_WIDTH_MULTIPLIER * imageScale,
justifyContent: "center",
alignItems: "center",
}}
@@ -55,13 +69,13 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
width: tileWidth,
height: tileHeight,
alignSelf: "center",
transform: [{ scale: 1.4 }],
transform: [{ scale: finalScale }],
borderRadius: 5,
}}
className='bg-neutral-800 overflow-hidden'
>
<Image
cachePolicy={"memory-disk"}
cachePolicy='memory-disk'
style={{
width: tileWidth * (trickplayInfo.data.TileWidth ?? 1),
height:
@@ -76,15 +90,8 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
contentFit='cover'
/>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
<Text style={{ marginTop: 30, fontSize: 16 }}>
{formatTime(time.hours, time.minutes, time.seconds)}
</Text>
</View>
);

View File

@@ -1,3 +1,5 @@
import { TVTypographyScale, useSettings } from "@/utils/atoms/settings";
/**
* TV Typography Scale
*
@@ -23,3 +25,29 @@ export const TVTypography = {
} as const;
export type TVTypographyKey = keyof typeof TVTypography;
const scaleMultipliers: Record<TVTypographyScale, number> = {
[TVTypographyScale.Small]: 0.85,
[TVTypographyScale.Default]: 1.0,
[TVTypographyScale.Large]: 1.2,
[TVTypographyScale.ExtraLarge]: 1.4,
};
/**
* Hook that returns scaled TV typography values based on user settings.
* Use this instead of the static TVTypography constant for dynamic scaling.
*/
export const useScaledTVTypography = () => {
const { settings } = useSettings();
const scale =
scaleMultipliers[settings.tvTypographyScale] ??
scaleMultipliers[TVTypographyScale.Default];
return {
display: Math.round(TVTypography.display * scale),
title: Math.round(TVTypography.title * scale),
heading: Math.round(TVTypography.heading * scale),
body: Math.round(TVTypography.body * scale),
callout: Math.round(TVTypography.callout * scale),
};
};

View File

@@ -2,6 +2,7 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { Platform } from "react-native";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -49,6 +50,8 @@ export const useItemQuery = (
return response.data.Items?.[0];
},
enabled: !!itemId,
staleTime: isOffline ? Infinity : 60 * 1000,
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,

View File

@@ -0,0 +1,6 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["GlassPosterModule"]
}
}

View 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";

View 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

View 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()
}
}

View 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")
}
}
}

View 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

View 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>;

View 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;

View 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 };

View File

@@ -0,0 +1,6 @@
export * from "./GlassPoster.types";
export {
default as GlassPosterModule,
isGlassEffectAvailable,
} from "./GlassPosterModule";
export { default as GlassPosterView } from "./GlassPosterView";

View File

@@ -7,7 +7,9 @@ export type {
DownloadStartedEvent,
} from "./background-downloader";
export { default as BackgroundDownloader } from "./background-downloader";
// Glass Poster (tvOS 26+)
export type { GlassPosterViewProps } from "./glass-poster";
export { GlassPosterView, isGlassEffectAvailable } from "./glass-poster";
// MPV Player (iOS + Android)
export type {
AudioTrack as MpvAudioTrack,

View File

@@ -98,7 +98,6 @@
"react-native-pager-view": "^6.9.1",
"react-native-reanimated": "~4.1.1",
"react-native-reanimated-carousel": "4.0.3",
"react-native-responsive-sizes": "^2.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.18.0",
"react-native-svg": "15.12.1",

Some files were not shown because too many files have changed in this diff Show More