mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-28 22:18:14 +00: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";
|
||||
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 || "", []);
|
||||
|
||||
@@ -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 || "", []);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
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 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";
|
||||
|
||||
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_unwatched_only": "Unwatched Only",
|
||||
"download_button": "Download"
|
||||
}
|
||||
},
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Next",
|
||||
|
||||
Reference in New Issue
Block a user