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 {
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -65,6 +66,7 @@ const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const router = useRouter(); const router = useRouter();
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();
const [orientation, _setOrientation] = useState( const [orientation, _setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP, ScreenOrientation.Orientation.PORTRAIT_UP,
@@ -294,7 +296,10 @@ const page: React.FC = () => {
width: posterSizes.poster, width: posterSizes.poster,
}} }}
> >
<TVFocusablePoster onPress={handlePress}> <TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
{item.Type === "Movie" && <MoviePoster item={item} />} {item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && ( {(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />
@@ -307,7 +312,7 @@ const page: React.FC = () => {
</View> </View>
); );
}, },
[router], [router, showItemActions],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -44,6 +44,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -108,6 +109,7 @@ const Page = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
const { showItemActions } = useTVItemActionModal();
// TV Filter queries // TV Filter queries
const { data: tvGenreOptions } = useQuery({ const { data: tvGenreOptions } = useQuery({
@@ -412,7 +414,10 @@ const Page = () => {
width: posterSizes.poster, width: posterSizes.poster,
}} }}
> >
<TVFocusablePoster onPress={handlePress}> <TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
{item.Type === "Movie" && <MoviePoster item={item} />} {item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && ( {(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />
@@ -425,7 +430,7 @@ const Page = () => {
</View> </View>
); );
}, },
[router], [router, showItemActions],
); );
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -42,6 +42,7 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons";
import { TVSearchPage } from "@/components/search/TVSearchPage"; import { TVSearchPage } from "@/components/search/TVSearchPage";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -69,6 +70,7 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(search)"; const from = (segments as string[])[2] || "(search)";
@@ -607,6 +609,7 @@ export default function search() {
loading={loading} loading={loading}
noResults={noResults} noResults={noResults}
onItemPress={handleItemPress} onItemPress={handleItemPress}
onItemLongPress={showItemActions}
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}
showDiscover={!!jellyseerrApi} showDiscover={!!jellyseerrApi}

View File

@@ -31,6 +31,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { import {
useDeleteWatchlist, useDeleteWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,
@@ -75,6 +76,7 @@ export default function WatchlistDetailScreen() {
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>();
@@ -211,6 +213,7 @@ export default function WatchlistDetailScreen() {
> >
<TVFocusablePoster <TVFocusablePoster
onPress={handlePress} onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0} hasTVPreferredFocus={index === 0}
> >
{item.Type === "Movie" && <MoviePoster item={item} />} {item.Type === "Movie" && <MoviePoster item={item} />}
@@ -222,7 +225,7 @@ export default function WatchlistDetailScreen() {
</View> </View>
); );
}, },
[router, typography], [router, showItemActions, typography],
); );
const renderItem = useCallback( const renderItem = useCallback(

View File

@@ -33,6 +33,7 @@ import {
TVFavoriteButton, TVFavoriteButton,
TVMetadataBadges, TVMetadataBadges,
TVOptionButton, TVOptionButton,
TVPlayedButton,
TVProgressBar, TVProgressBar,
TVRefreshButton, TVRefreshButton,
TVSeriesNavigation, TVSeriesNavigation,
@@ -646,6 +647,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
</Text> </Text>
</TVButton> </TVButton>
<TVFavoriteButton item={item} /> <TVFavoriteButton item={item} />
<TVPlayedButton item={item} />
<TVRefreshButton itemId={item.Id} /> <TVRefreshButton itemId={item.Id} />
</View> </View>

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster.tv"; import MoviePoster from "@/components/posters/MoviePoster.tv";
import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -47,10 +48,18 @@ const SCALE_PADDING = 20;
const TVFocusablePoster: React.FC<{ const TVFocusablePoster: React.FC<{
children: React.ReactNode; children: React.ReactNode;
onPress: () => void; onPress: () => void;
onLongPress?: () => void;
hasTVPreferredFocus?: boolean; hasTVPreferredFocus?: boolean;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => { }> = ({
children,
onPress,
onLongPress,
hasTVPreferredFocus,
onFocus,
onBlur,
}) => {
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current; const scale = useRef(new Animated.Value(1)).current;
@@ -65,6 +74,7 @@ const TVFocusablePoster: React.FC<{
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
animateTo(1.05); animateTo(1.05);
@@ -100,6 +110,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter(); const router = useRouter();
const { showItemActions } = useTVItemActionModal();
const segments = useSegments(); const segments = useSegments();
const from = (segments as string[])[2] || "(home)"; const from = (segments as string[])[2] || "(home)";
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
@@ -292,6 +303,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
<View style={{ marginRight: ITEM_GAP }}> <View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster <TVFocusablePoster
onPress={() => handleItemPress(filmItem)} onPress={() => handleItemPress(filmItem)}
onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)} onFocus={() => setFocusedItem(filmItem)}
hasTVPreferredFocus={isFirstSection && index === 0} hasTVPreferredFocus={isFirstSection && index === 0}
> >
@@ -304,7 +316,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
</TVFocusablePoster> </TVFocusablePoster>
</View> </View>
), ),
[handleItemPress], [handleItemPress, showItemActions],
); );
if (isLoadingActor) { if (isLoadingActor) {

View File

@@ -106,6 +106,7 @@ interface TVSearchPageProps {
loading: boolean; loading: boolean;
noResults: boolean; noResults: boolean;
onItemPress: (item: BaseItemDto) => void; onItemPress: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
// Jellyseerr/Discover props // Jellyseerr/Discover props
searchType: SearchType; searchType: SearchType;
setSearchType: (type: SearchType) => void; setSearchType: (type: SearchType) => void;
@@ -138,6 +139,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
loading, loading,
noResults, noResults,
onItemPress, onItemPress,
onItemLongPress,
searchType, searchType,
setSearchType, setSearchType,
showDiscover, showDiscover,
@@ -273,6 +275,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
orientation={section.orientation || "vertical"} orientation={section.orientation || "vertical"}
isFirstSection={index === 0} isFirstSection={index === 0}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes( ["artists", "albums", "songs", "playlists"].includes(
section.key, section.key,

View File

@@ -143,6 +143,7 @@ interface TVSearchSectionProps extends ViewProps {
disabled?: boolean; disabled?: boolean;
isFirstSection?: boolean; isFirstSection?: boolean;
onItemPress: (item: BaseItemDto) => void; onItemPress: (item: BaseItemDto) => void;
onItemLongPress?: (item: BaseItemDto) => void;
imageUrlGetter?: (item: BaseItemDto) => string | undefined; imageUrlGetter?: (item: BaseItemDto) => string | undefined;
} }
@@ -153,6 +154,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
disabled = false, disabled = false,
isFirstSection = false, isFirstSection = false,
onItemPress, onItemPress,
onItemLongPress,
imageUrlGetter, imageUrlGetter,
...props ...props
}) => { }) => {
@@ -328,6 +330,9 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}> <View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
<TVFocusablePoster <TVFocusablePoster
onPress={() => onItemPress(item)} onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled} hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus} onFocus={handleItemFocus}
onBlur={handleItemBlur} onBlur={handleItemBlur}
@@ -344,6 +349,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
isFirstSection, isFirstSection,
itemWidth, itemWidth,
onItemPress, onItemPress,
onItemLongPress,
handleItemFocus, handleItemFocus,
handleItemBlur, handleItemBlur,
disabled, disabled,

View File

@@ -26,6 +26,7 @@ interface TVEpisodeCardProps {
/** Shows a "Now Playing" badge on the card */ /** Shows a "Now Playing" badge on the card */
isCurrent?: boolean; isCurrent?: boolean;
onPress: () => void; onPress: () => void;
onLongPress?: () => void;
onFocus?: () => void; onFocus?: () => void;
onBlur?: () => void; onBlur?: () => void;
/** Setter function for the ref (for focus guide destinations) */ /** Setter function for the ref (for focus guide destinations) */
@@ -39,6 +40,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
focusableWhenDisabled = false, focusableWhenDisabled = false,
isCurrent = false, isCurrent = false,
onPress, onPress,
onLongPress,
onFocus, onFocus,
onBlur, onBlur,
refSetter, refSetter,
@@ -123,6 +125,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
> >
<TVFocusablePoster <TVFocusablePoster
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}
hasTVPreferredFocus={hasTVPreferredFocus} hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled} disabled={disabled}
focusableWhenDisabled={focusableWhenDisabled} focusableWhenDisabled={focusableWhenDisabled}

View File

@@ -16,6 +16,8 @@ interface TVEpisodeListProps {
disabled?: boolean; disabled?: boolean;
/** Handler when an episode is pressed */ /** Handler when an episode is pressed */
onEpisodePress: (episode: BaseItemDto) => void; onEpisodePress: (episode: BaseItemDto) => void;
/** Called when any episode is long-pressed */
onEpisodeLongPress?: (episode: BaseItemDto) => void;
/** Called when any episode gains focus */ /** Called when any episode gains focus */
onFocus?: () => void; onFocus?: () => void;
/** Called when any episode loses focus */ /** Called when any episode loses focus */
@@ -35,6 +37,7 @@ export const TVEpisodeList: React.FC<TVEpisodeListProps> = ({
currentEpisodeId, currentEpisodeId,
disabled = false, disabled = false,
onEpisodePress, onEpisodePress,
onEpisodeLongPress,
onFocus, onFocus,
onBlur, onBlur,
scrollViewRef, scrollViewRef,
@@ -79,6 +82,9 @@ export const TVEpisodeList: React.FC<TVEpisodeListProps> = ({
key={episode.Id} key={episode.Id}
episode={episode} episode={episode}
onPress={() => onEpisodePress(episode)} onPress={() => onEpisodePress(episode)}
onLongPress={
onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined
}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
disabled={isCurrent || disabled} disabled={isCurrent || disabled}

View File

@@ -32,6 +32,7 @@ import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton"; import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
@@ -225,6 +226,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { getDownloadedItems, downloadedItems } = useDownload(); const { getDownloadedItems, downloadedItems } = useDownload();
const { showSeasonModal } = useTVSeriesSeasonModal(); const { showSeasonModal } = useTVSeriesSeasonModal();
const { showItemActions } = useTVItemActionModal();
const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom);
const isSeasonModalVisible = seasonModalState !== null; const isSeasonModalVisible = seasonModalState !== null;
@@ -625,6 +627,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
episodes={episodesForSeason} episodes={episodesForSeason}
disabled={isSeasonModalVisible} disabled={isSeasonModalVisible}
onEpisodePress={handleEpisodePress} onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
onFocus={handleEpisodeFocus} onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur} onBlur={handleEpisodeBlur}
scrollViewRef={episodeListRef} scrollViewRef={episodeListRef}

View File

@@ -10,6 +10,7 @@ import {
export interface TVFocusablePosterProps { export interface TVFocusablePosterProps {
children: React.ReactNode; children: React.ReactNode;
onPress: () => void; onPress: () => void;
onLongPress?: () => void;
hasTVPreferredFocus?: boolean; hasTVPreferredFocus?: boolean;
glowColor?: "white" | "purple"; glowColor?: "white" | "purple";
scaleAmount?: number; scaleAmount?: number;
@@ -26,6 +27,7 @@ export interface TVFocusablePosterProps {
export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
children, children,
onPress, onPress,
onLongPress,
hasTVPreferredFocus = false, hasTVPreferredFocus = false,
glowColor = "white", glowColor = "white",
scaleAmount = 1.05, scaleAmount = 1.05,
@@ -53,6 +55,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
<Pressable <Pressable
ref={refSetter} ref={refSetter}
onPress={onPress} onPress={onPress}
onLongPress={onLongPress}
onFocus={() => { onFocus={() => {
setFocused(true); setFocused(true);
animateTo(scaleAmount); animateTo(scaleAmount);

View File

@@ -0,0 +1,33 @@
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import React from "react";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { TVButton } from "./TVButton";
export interface TVPlayedButtonProps {
item: BaseItemDto;
disabled?: boolean;
}
export const TVPlayedButton: React.FC<TVPlayedButtonProps> = ({
item,
disabled,
}) => {
const isPlayed = item.UserData?.Played ?? false;
const toggle = useMarkAsPlayed([item]);
return (
<TVButton
onPress={() => toggle(!isPlayed)}
variant='glass'
square
disabled={disabled}
>
<Ionicons
name={isPlayed ? "checkmark-circle" : "checkmark-circle-outline"}
size={28}
color='#FFFFFF'
/>
</TVButton>
);
};

View File

@@ -43,6 +43,8 @@ export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard"; export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector"; export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector"; export { TVOptionSelector } from "./TVOptionSelector";
export type { TVPlayedButtonProps } from "./TVPlayedButton";
export { TVPlayedButton } from "./TVPlayedButton";
export type { TVProgressBarProps } from "./TVProgressBar"; export type { TVProgressBarProps } from "./TVProgressBar";
export { TVProgressBar } from "./TVProgressBar"; export { TVProgressBar } from "./TVProgressBar";
export type { TVRefreshButtonProps } from "./TVRefreshButton"; export type { TVRefreshButtonProps } from "./TVRefreshButton";

View File

@@ -0,0 +1,82 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Alert } from "react-native";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
export const useTVItemActionModal = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
const showItemActions = useCallback(
(item: BaseItemDto) => {
const isPlayed = item.UserData?.Played ?? false;
const itemTitle =
item.Type === "Episode"
? `${item.SeriesName} - ${item.Name}`
: (item.Name ?? "");
const actionLabel = isPlayed
? t("item_card.mark_unplayed")
: t("item_card.mark_played");
Alert.alert(itemTitle, undefined, [
{ text: t("common.cancel"), style: "cancel" },
{
text: actionLabel,
onPress: async () => {
if (!item.Id) return;
// Optimistic update
queryClient.setQueriesData<BaseItemDto | null | undefined>(
{ queryKey: ["item", item.Id] },
(old) => {
if (!old) return old;
return {
...old,
UserData: {
...old.UserData,
Played: !isPlayed,
PlaybackPositionTicks: 0,
PlayedPercentage: 0,
},
};
},
);
try {
if (!isPlayed) {
await markItemPlayed(item.Id);
} else {
await markItemUnplayed(item.Id);
}
} catch {
// Revert on failure
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
} finally {
await invalidatePlaybackProgressCache();
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
}
},
},
]);
},
[
t,
queryClient,
markItemPlayed,
markItemUnplayed,
invalidatePlaybackProgressCache,
],
);
return { showItemActions };
};

View File

@@ -717,7 +717,9 @@
"download_x_item": "Download {{item_count}} Items", "download_x_item": "Download {{item_count}} Items",
"download_unwatched_only": "Unwatched Only", "download_unwatched_only": "Unwatched Only",
"download_button": "Download" "download_button": "Download"
} },
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched"
}, },
"live_tv": { "live_tv": {
"next": "Next", "next": "Next",