feat(tv): add long-press mark as watched action using alert dialog

This commit is contained in:
Fredrik Burmester
2026-01-28 20:36:57 +01:00
parent 8dcd4c40f9
commit 2ff9625903
21 changed files with 212 additions and 13 deletions

View File

@@ -36,6 +36,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -77,6 +78,7 @@ export const Home = () => {
retryCheck,
} = useNetworkStatus();
const _invalidateCache = useInvalidatePlaybackProgressCache();
const { showItemActions } = useTVItemActionModal();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
// Dynamic backdrop state with debounce
@@ -745,7 +747,11 @@ export const Home = () => {
>
{/* Hero Carousel - Apple TV+ style featured content */}
{showHero && heroItems && (
<TVHeroCarousel items={heroItems} onItemFocus={handleItemFocus} />
<TVHeroCarousel
items={heroItems}
onItemFocus={handleItemFocus}
onItemLongPress={showItemActions}
/>
)}
<View

View File

@@ -21,6 +21,7 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import ContinueWatchingPoster from "../ContinueWatchingPoster.tv";
import SeriesPoster from "../posters/SeriesPoster.tv";
@@ -202,6 +203,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
const effectivePageSize = Math.max(1, pageSize);
const hasCalledOnLoaded = useRef(false);
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -362,6 +364,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
<View style={{ marginRight: ITEM_GAP, width: itemWidth }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
@@ -381,6 +384,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
isFirstSection,
itemWidth,
handleItemPress,
showItemActions,
handleItemFocus,
handleItemBlur,
typography,

View File

@@ -17,6 +17,7 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
@@ -70,6 +71,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -142,6 +144,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
@@ -152,7 +155,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
</View>
);
},
[handleItemPress, onItemFocus, typography],
[handleItemPress, showItemActions, onItemFocus, typography],
);
if (!isLoading && (!items || items.length === 0)) return null;

View File

@@ -17,6 +17,7 @@ import { TVFocusablePoster } from "@/components/tv/TVFocusablePoster";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { createStreamystatsApi } from "@/utils/streamystats/api";
@@ -74,6 +75,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
const user = useAtomValue(userAtom);
const { settings } = useSettings();
const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments();
const from = (segments as string[])[2] || "(home)";
@@ -203,6 +205,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View style={{ marginRight: ITEM_GAP, width: posterSizes.poster }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onLongPress={() => showItemActions(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
@@ -213,7 +216,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
</View>
);
},
[handleItemPress, onItemFocus, typography],
[handleItemPress, showItemActions, onItemFocus, typography],
);
if (!streamyStatsEnabled) return null;

View File

@@ -43,6 +43,7 @@ const CARD_PADDING = 60;
interface TVHeroCarouselProps {
items: BaseItemDto[];
onItemFocus?: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
}
interface HeroCardProps {
@@ -51,10 +52,11 @@ interface HeroCardProps {
cardWidth: number;
onFocus: (item: BaseItemDto) => void;
onPress: (item: BaseItemDto) => void;
onLongPress?: (item: BaseItemDto) => void;
}
const HeroCard: React.FC<HeroCardProps> = React.memo(
({ item, isFirst, cardWidth, onFocus, onPress }) => {
({ item, isFirst, cardWidth, onFocus, onPress, onLongPress }) => {
const api = useAtomValue(apiAtom);
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
@@ -113,11 +115,16 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
onPress(item);
}, [onPress, item]);
const handleLongPress = useCallback(() => {
onLongPress?.(item);
}, [onLongPress, item]);
// Use glass poster for tvOS 26+
if (useGlass) {
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
@@ -141,6 +148,7 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
return (
<Pressable
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
@@ -195,6 +203,7 @@ const BACKDROP_DEBOUNCE_MS = 300;
export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
items,
onItemFocus,
onItemLongPress,
}) => {
const typography = useScaledTVTypography();
const posterSizes = useScaledTVPosterSizes();
@@ -359,9 +368,10 @@ export const TVHeroCarousel: React.FC<TVHeroCarouselProps> = ({
cardWidth={posterSizes.heroCard}
onFocus={handleCardFocus}
onPress={handleCardPress}
onLongPress={onItemLongPress}
/>
),
[handleCardFocus, handleCardPress, posterSizes.heroCard],
[handleCardFocus, handleCardPress, onItemLongPress, posterSizes.heroCard],
);
// Memoize keyExtractor