From 2ff96259039fd317b282540f7e67671db5dbca29 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Jan 2026 20:36:57 +0100 Subject: [PATCH] feat(tv): add long-press mark as watched action using alert dialog --- .../collections/[collectionId].tsx | 9 +- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 9 +- app/(auth)/(tabs)/(search)/index.tsx | 3 + .../(tabs)/(watchlists)/[watchlistId].tsx | 5 +- components/ItemContent.tv.tsx | 2 + components/home/Home.tv.tsx | 8 +- .../InfiniteScrollingCollectionList.tv.tsx | 4 + .../StreamystatsPromotedWatchlists.tv.tsx | 5 +- .../home/StreamystatsRecommendations.tv.tsx | 5 +- components/home/TVHeroCarousel.tsx | 14 +++- components/persons/TVActorPage.tsx | 16 +++- components/search/TVSearchPage.tsx | 3 + components/search/TVSearchSection.tsx | 6 ++ components/series/TVEpisodeCard.tsx | 3 + components/series/TVEpisodeList.tsx | 6 ++ components/series/TVSeriesPage.tsx | 3 + components/tv/TVFocusablePoster.tsx | 3 + components/tv/TVPlayedButton.tsx | 33 ++++++++ components/tv/index.ts | 2 + hooks/useTVItemActionModal.ts | 82 +++++++++++++++++++ translations/en.json | 4 +- 21 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 components/tv/TVPlayedButton.tsx create mode 100644 hooks/useTVItemActionModal.ts diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx index 1a6ca0b7..749d8508 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/collections/[collectionId].tsx @@ -36,6 +36,7 @@ import { } from "@/components/tv"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -65,6 +66,7 @@ const page: React.FC = () => { const navigation = useNavigation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + const { showItemActions } = useTVItemActionModal(); const { width: screenWidth } = useWindowDimensions(); const [orientation, _setOrientation] = useState( ScreenOrientation.Orientation.PORTRAIT_UP, @@ -294,7 +296,10 @@ const page: React.FC = () => { width: posterSizes.poster, }} > - + showItemActions(item)} + > {item.Type === "Movie" && } {(item.Type === "Series" || item.Type === "Episode") && ( @@ -307,7 +312,7 @@ const page: React.FC = () => { ); }, - [router], + [router, showItemActions], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 02bc671e..9fe9733c 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -44,6 +44,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -108,6 +109,7 @@ const Page = () => { const { t } = useTranslation(); const router = useRouter(); const { showOptions } = useTVOptionModal(); + const { showItemActions } = useTVItemActionModal(); // TV Filter queries const { data: tvGenreOptions } = useQuery({ @@ -412,7 +414,10 @@ const Page = () => { width: posterSizes.poster, }} > - + showItemActions(item)} + > {item.Type === "Movie" && } {(item.Type === "Series" || item.Type === "Episode") && ( @@ -425,7 +430,7 @@ const Page = () => { ); }, - [router], + [router, showItemActions], ); const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 831d96c1..5c486866 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -42,6 +42,7 @@ import { SearchTabButtons } from "@/components/search/SearchTabButtons"; import { TVSearchPage } from "@/components/search/TVSearchPage"; import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; @@ -69,6 +70,7 @@ export default function search() { const params = useLocalSearchParams(); const insets = useSafeAreaInsets(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(search)"; @@ -607,6 +609,7 @@ export default function search() { loading={loading} noResults={noResults} onItemPress={handleItemPress} + onItemLongPress={showItemActions} searchType={searchType} setSearchType={setSearchType} showDiscover={!!jellyseerrApi} diff --git a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx index 0adee973..07b03b17 100644 --- a/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx +++ b/app/(auth)/(tabs)/(watchlists)/[watchlistId].tsx @@ -31,6 +31,7 @@ import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useOrientation } from "@/hooks/useOrientation"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useDeleteWatchlist, useRemoveFromWatchlist, @@ -75,6 +76,7 @@ export default function WatchlistDetailScreen() { const posterSizes = useScaledTVPosterSizes(); const { t } = useTranslation(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { watchlistId } = useLocalSearchParams<{ watchlistId: string }>(); @@ -211,6 +213,7 @@ export default function WatchlistDetailScreen() { > showItemActions(item)} hasTVPreferredFocus={index === 0} > {item.Type === "Movie" && } @@ -222,7 +225,7 @@ export default function WatchlistDetailScreen() { ); }, - [router, typography], + [router, showItemActions, typography], ); const renderItem = useCallback( diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index ae91ca6a..acc2f583 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -33,6 +33,7 @@ import { TVFavoriteButton, TVMetadataBadges, TVOptionButton, + TVPlayedButton, TVProgressBar, TVRefreshButton, TVSeriesNavigation, @@ -646,6 +647,7 @@ export const ItemContentTV: React.FC = React.memo( + diff --git a/components/home/Home.tv.tsx b/components/home/Home.tv.tsx index 1b9ee9dd..770597c2 100644 --- a/components/home/Home.tv.tsx +++ b/components/home/Home.tv.tsx @@ -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>(new Set()); // Dynamic backdrop state with debounce @@ -745,7 +747,11 @@ export const Home = () => { > {/* Hero Carousel - Apple TV+ style featured content */} {showHero && heroItems && ( - + )} = ({ 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 = ({ handleItemPress(item)} + onLongPress={() => showItemActions(item)} hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} onBlur={handleItemBlur} @@ -381,6 +384,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ isFirstSection, itemWidth, handleItemPress, + showItemActions, handleItemFocus, handleItemBlur, typography, diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 1c5e69a4..07ba09b2 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -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 = ({ 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 = ({ handleItemPress(item)} + onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} hasTVPreferredFocus={false} > @@ -152,7 +155,7 @@ const WatchlistSection: React.FC = ({ ); }, - [handleItemPress, onItemFocus, typography], + [handleItemPress, showItemActions, onItemFocus, typography], ); if (!isLoading && (!items || items.length === 0)) return null; diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index 96640773..c8a9cf0b 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -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 = ({ 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 = ({ handleItemPress(item)} + onLongPress={() => showItemActions(item)} onFocus={() => onItemFocus?.(item)} hasTVPreferredFocus={false} > @@ -213,7 +216,7 @@ export const StreamystatsRecommendations: React.FC = ({ ); }, - [handleItemPress, onItemFocus, typography], + [handleItemPress, showItemActions, onItemFocus, typography], ); if (!streamyStatsEnabled) return null; diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index 9293789e..527bd74d 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -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 = 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 = React.memo( onPress(item); }, [onPress, item]); + const handleLongPress = useCallback(() => { + onLongPress?.(item); + }, [onLongPress, item]); + // Use glass poster for tvOS 26+ if (useGlass) { return ( = React.memo( return ( = ({ items, onItemFocus, + onItemLongPress, }) => { const typography = useScaledTVTypography(); const posterSizes = useScaledTVPosterSizes(); @@ -359,9 +368,10 @@ export const TVHeroCarousel: React.FC = ({ cardWidth={posterSizes.heroCard} onFocus={handleCardFocus} onPress={handleCardPress} + onLongPress={onItemLongPress} /> ), - [handleCardFocus, handleCardPress, posterSizes.heroCard], + [handleCardFocus, handleCardPress, onItemLongPress, posterSizes.heroCard], ); // Memoize keyExtractor diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx index 4f543be8..eacfd7b1 100644 --- a/components/persons/TVActorPage.tsx +++ b/components/persons/TVActorPage.tsx @@ -31,6 +31,7 @@ import { Loader } from "@/components/Loader"; import MoviePoster from "@/components/posters/MoviePoster.tv"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; @@ -47,10 +48,18 @@ const SCALE_PADDING = 20; const TVFocusablePoster: React.FC<{ children: React.ReactNode; onPress: () => void; + onLongPress?: () => void; hasTVPreferredFocus?: boolean; onFocus?: () => void; onBlur?: () => void; -}> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => { +}> = ({ + children, + onPress, + onLongPress, + hasTVPreferredFocus, + onFocus, + onBlur, +}) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; @@ -65,6 +74,7 @@ const TVFocusablePoster: React.FC<{ return ( { setFocused(true); animateTo(1.05); @@ -100,6 +110,7 @@ export const TVActorPage: React.FC = ({ personId }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); + const { showItemActions } = useTVItemActionModal(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; const posterSizes = useScaledTVPosterSizes(); @@ -292,6 +303,7 @@ export const TVActorPage: React.FC = ({ personId }) => { handleItemPress(filmItem)} + onLongPress={() => showItemActions(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={isFirstSection && index === 0} > @@ -304,7 +316,7 @@ export const TVActorPage: React.FC = ({ personId }) => { ), - [handleItemPress], + [handleItemPress, showItemActions], ); if (isLoadingActor) { diff --git a/components/search/TVSearchPage.tsx b/components/search/TVSearchPage.tsx index 79d7c379..feba7c2d 100644 --- a/components/search/TVSearchPage.tsx +++ b/components/search/TVSearchPage.tsx @@ -106,6 +106,7 @@ interface TVSearchPageProps { loading: boolean; noResults: boolean; onItemPress: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; // Jellyseerr/Discover props searchType: SearchType; setSearchType: (type: SearchType) => void; @@ -138,6 +139,7 @@ export const TVSearchPage: React.FC = ({ loading, noResults, onItemPress, + onItemLongPress, searchType, setSearchType, showDiscover, @@ -273,6 +275,7 @@ export const TVSearchPage: React.FC = ({ orientation={section.orientation || "vertical"} isFirstSection={index === 0} onItemPress={onItemPress} + onItemLongPress={onItemLongPress} imageUrlGetter={ ["artists", "albums", "songs", "playlists"].includes( section.key, diff --git a/components/search/TVSearchSection.tsx b/components/search/TVSearchSection.tsx index f9eca844..310da15e 100644 --- a/components/search/TVSearchSection.tsx +++ b/components/search/TVSearchSection.tsx @@ -143,6 +143,7 @@ interface TVSearchSectionProps extends ViewProps { disabled?: boolean; isFirstSection?: boolean; onItemPress: (item: BaseItemDto) => void; + onItemLongPress?: (item: BaseItemDto) => void; imageUrlGetter?: (item: BaseItemDto) => string | undefined; } @@ -153,6 +154,7 @@ export const TVSearchSection: React.FC = ({ disabled = false, isFirstSection = false, onItemPress, + onItemLongPress, imageUrlGetter, ...props }) => { @@ -328,6 +330,9 @@ export const TVSearchSection: React.FC = ({ onItemPress(item)} + onLongPress={ + onItemLongPress ? () => onItemLongPress(item) : undefined + } hasTVPreferredFocus={isFirstItem && !disabled} onFocus={handleItemFocus} onBlur={handleItemBlur} @@ -344,6 +349,7 @@ export const TVSearchSection: React.FC = ({ isFirstSection, itemWidth, onItemPress, + onItemLongPress, handleItemFocus, handleItemBlur, disabled, diff --git a/components/series/TVEpisodeCard.tsx b/components/series/TVEpisodeCard.tsx index 262dc323..af1d8353 100644 --- a/components/series/TVEpisodeCard.tsx +++ b/components/series/TVEpisodeCard.tsx @@ -26,6 +26,7 @@ interface TVEpisodeCardProps { /** Shows a "Now Playing" badge on the card */ isCurrent?: boolean; onPress: () => void; + onLongPress?: () => void; onFocus?: () => void; onBlur?: () => void; /** Setter function for the ref (for focus guide destinations) */ @@ -39,6 +40,7 @@ export const TVEpisodeCard: React.FC = ({ focusableWhenDisabled = false, isCurrent = false, onPress, + onLongPress, onFocus, onBlur, refSetter, @@ -123,6 +125,7 @@ export const TVEpisodeCard: React.FC = ({ > void; + /** Called when any episode is long-pressed */ + onEpisodeLongPress?: (episode: BaseItemDto) => void; /** Called when any episode gains focus */ onFocus?: () => void; /** Called when any episode loses focus */ @@ -35,6 +37,7 @@ export const TVEpisodeList: React.FC = ({ currentEpisodeId, disabled = false, onEpisodePress, + onEpisodeLongPress, onFocus, onBlur, scrollViewRef, @@ -79,6 +82,9 @@ export const TVEpisodeList: React.FC = ({ key={episode.Id} episode={episode} onPress={() => onEpisodePress(episode)} + onLongPress={ + onEpisodeLongPress ? () => onEpisodeLongPress(episode) : undefined + } onFocus={onFocus} onBlur={onBlur} disabled={isCurrent || disabled} diff --git a/components/series/TVSeriesPage.tsx b/components/series/TVSeriesPage.tsx index ff533f02..e6440e1d 100644 --- a/components/series/TVSeriesPage.tsx +++ b/components/series/TVSeriesPage.tsx @@ -32,6 +32,7 @@ import { TVSeriesHeader } from "@/components/series/TVSeriesHeader"; import { TVFavoriteButton } from "@/components/tv/TVFavoriteButton"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; +import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVSeriesSeasonModal } from "@/hooks/useTVSeriesSeasonModal"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -225,6 +226,7 @@ export const TVSeriesPage: React.FC = ({ const [user] = useAtom(userAtom); const { getDownloadedItems, downloadedItems } = useDownload(); const { showSeasonModal } = useTVSeriesSeasonModal(); + const { showItemActions } = useTVItemActionModal(); const seasonModalState = useAtomValue(tvSeriesSeasonModalAtom); const isSeasonModalVisible = seasonModalState !== null; @@ -625,6 +627,7 @@ export const TVSeriesPage: React.FC = ({ episodes={episodesForSeason} disabled={isSeasonModalVisible} onEpisodePress={handleEpisodePress} + onEpisodeLongPress={showItemActions} onFocus={handleEpisodeFocus} onBlur={handleEpisodeBlur} scrollViewRef={episodeListRef} diff --git a/components/tv/TVFocusablePoster.tsx b/components/tv/TVFocusablePoster.tsx index 1a0b7653..337cbc2a 100644 --- a/components/tv/TVFocusablePoster.tsx +++ b/components/tv/TVFocusablePoster.tsx @@ -10,6 +10,7 @@ import { export interface TVFocusablePosterProps { children: React.ReactNode; onPress: () => void; + onLongPress?: () => void; hasTVPreferredFocus?: boolean; glowColor?: "white" | "purple"; scaleAmount?: number; @@ -26,6 +27,7 @@ export interface TVFocusablePosterProps { export const TVFocusablePoster: React.FC = ({ children, onPress, + onLongPress, hasTVPreferredFocus = false, glowColor = "white", scaleAmount = 1.05, @@ -53,6 +55,7 @@ export const TVFocusablePoster: React.FC = ({ { setFocused(true); animateTo(scaleAmount); diff --git a/components/tv/TVPlayedButton.tsx b/components/tv/TVPlayedButton.tsx new file mode 100644 index 00000000..8ab8e4bb --- /dev/null +++ b/components/tv/TVPlayedButton.tsx @@ -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 = ({ + item, + disabled, +}) => { + const isPlayed = item.UserData?.Played ?? false; + const toggle = useMarkAsPlayed([item]); + + return ( + toggle(!isPlayed)} + variant='glass' + square + disabled={disabled} + > + + + ); +}; diff --git a/components/tv/index.ts b/components/tv/index.ts index 14e71b2d..8352ba76 100644 --- a/components/tv/index.ts +++ b/components/tv/index.ts @@ -43,6 +43,8 @@ export type { TVOptionCardProps } from "./TVOptionCard"; export { TVOptionCard } from "./TVOptionCard"; export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector"; export { TVOptionSelector } from "./TVOptionSelector"; +export type { TVPlayedButtonProps } from "./TVPlayedButton"; +export { TVPlayedButton } from "./TVPlayedButton"; export type { TVProgressBarProps } from "./TVProgressBar"; export { TVProgressBar } from "./TVProgressBar"; export type { TVRefreshButtonProps } from "./TVRefreshButton"; diff --git a/hooks/useTVItemActionModal.ts b/hooks/useTVItemActionModal.ts new file mode 100644 index 00000000..3c547c0d --- /dev/null +++ b/hooks/useTVItemActionModal.ts @@ -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( + { 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 }; +}; diff --git a/translations/en.json b/translations/en.json index 6e016a12..bcc2c03d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -717,7 +717,9 @@ "download_x_item": "Download {{item_count}} Items", "download_unwatched_only": "Unwatched Only", "download_button": "Download" - } + }, + "mark_played": "Mark as Watched", + "mark_unplayed": "Mark as Unwatched" }, "live_tv": { "next": "Next",