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";
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,
}}
>
<TVFocusablePoster onPress={handlePress}>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
@@ -307,7 +312,7 @@ const page: React.FC = () => {
</View>
);
},
[router],
[router, showItemActions],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);

View File

@@ -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,
}}
>
<TVFocusablePoster onPress={handlePress}>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
{(item.Type === "Series" || item.Type === "Episode") && (
<SeriesPoster item={item} />
@@ -425,7 +430,7 @@ const Page = () => {
</View>
);
},
[router],
[router, showItemActions],
);
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 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}

View File

@@ -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() {
>
<TVFocusablePoster
onPress={handlePress}
onLongPress={() => showItemActions(item)}
hasTVPreferredFocus={index === 0}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
@@ -222,7 +225,7 @@ export default function WatchlistDetailScreen() {
</View>
);
},
[router, typography],
[router, showItemActions, typography],
);
const renderItem = useCallback(

View File

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

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

View File

@@ -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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
@@ -100,6 +110,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ 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<TVActorPageProps> = ({ personId }) => {
<View style={{ marginRight: ITEM_GAP }}>
<TVFocusablePoster
onPress={() => handleItemPress(filmItem)}
onLongPress={() => showItemActions(filmItem)}
onFocus={() => setFocusedItem(filmItem)}
hasTVPreferredFocus={isFirstSection && index === 0}
>
@@ -304,7 +316,7 @@ export const TVActorPage: React.FC<TVActorPageProps> = ({ personId }) => {
</TVFocusablePoster>
</View>
),
[handleItemPress],
[handleItemPress, showItemActions],
);
if (isLoadingActor) {

View File

@@ -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<TVSearchPageProps> = ({
loading,
noResults,
onItemPress,
onItemLongPress,
searchType,
setSearchType,
showDiscover,
@@ -273,6 +275,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
orientation={section.orientation || "vertical"}
isFirstSection={index === 0}
onItemPress={onItemPress}
onItemLongPress={onItemLongPress}
imageUrlGetter={
["artists", "albums", "songs", "playlists"].includes(
section.key,

View File

@@ -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<TVSearchSectionProps> = ({
disabled = false,
isFirstSection = false,
onItemPress,
onItemLongPress,
imageUrlGetter,
...props
}) => {
@@ -328,6 +330,9 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
<View style={{ marginRight: ITEM_GAP, width: actualItemWidth }}>
<TVFocusablePoster
onPress={() => onItemPress(item)}
onLongPress={
onItemLongPress ? () => onItemLongPress(item) : undefined
}
hasTVPreferredFocus={isFirstItem && !disabled}
onFocus={handleItemFocus}
onBlur={handleItemBlur}
@@ -344,6 +349,7 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
isFirstSection,
itemWidth,
onItemPress,
onItemLongPress,
handleItemFocus,
handleItemBlur,
disabled,

View File

@@ -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<TVEpisodeCardProps> = ({
focusableWhenDisabled = false,
isCurrent = false,
onPress,
onLongPress,
onFocus,
onBlur,
refSetter,
@@ -123,6 +125,7 @@ export const TVEpisodeCard: React.FC<TVEpisodeCardProps> = ({
>
<TVFocusablePoster
onPress={onPress}
onLongPress={onLongPress}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={disabled}
focusableWhenDisabled={focusableWhenDisabled}

View File

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

View File

@@ -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<TVSeriesPageProps> = ({
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<TVSeriesPageProps> = ({
episodes={episodesForSeason}
disabled={isSeasonModalVisible}
onEpisodePress={handleEpisodePress}
onEpisodeLongPress={showItemActions}
onFocus={handleEpisodeFocus}
onBlur={handleEpisodeBlur}
scrollViewRef={episodeListRef}

View File

@@ -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<TVFocusablePosterProps> = ({
children,
onPress,
onLongPress,
hasTVPreferredFocus = false,
glowColor = "white",
scaleAmount = 1.05,
@@ -53,6 +55,7 @@ export const TVFocusablePoster: React.FC<TVFocusablePosterProps> = ({
<Pressable
ref={refSetter}
onPress={onPress}
onLongPress={onLongPress}
onFocus={() => {
setFocused(true);
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 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";

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_unwatched_only": "Unwatched Only",
"download_button": "Download"
}
},
"mark_played": "Mark as Watched",
"mark_unplayed": "Mark as Unwatched"
},
"live_tv": {
"next": "Next",