mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-15 00:43:08 +01:00
feat(tv): add long-press mark as watched action using alert dialog
This commit is contained in:
@@ -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 || "", []);
|
||||||
|
|||||||
@@ -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 || "", []);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
33
components/tv/TVPlayedButton.tsx
Normal file
33
components/tv/TVPlayedButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
82
hooks/useTVItemActionModal.ts
Normal file
82
hooks/useTVItemActionModal.ts
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user