feat: add "Mark Unwatched" toggle for movies on web and native (#52)

- Replace the static "Mark Watched" button with a toggle that shows "Mark Unwatched" (destructive style) when a movie is already completed
- Add `handleUnwatchMovie` to web `useTitleActions` with optimistic update (reverts status to `in_watchlist` on error)
- Add `unwatchMovie` mutation to native `useTitleActions` hook and `titleActions` imperative helper
- Update poster-card and upcoming-row context menus to show the correct action based on current watch status
- Wire the `M` keyboard shortcut on the title detail page to unwatch when the movie is already marked watched
- Add i18n strings for "Mark Unwatched", "Marked as unwatched", and "Failed to mark as unwatched"
This commit is contained in:
2026-07-12 16:53:52 -04:00
committed by GitHub
parent 0c747a9275
commit 553fe98013
9 changed files with 143 additions and 21 deletions
+32 -8
View File
@@ -12,6 +12,7 @@ import {
IconStarFilled,
IconThumbUp,
IconUsers,
IconX,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
@@ -98,22 +99,27 @@ export default function TitleDetailScreen() {
const { back } = useRouter();
const useAutomaticInsets = process.env.EXPO_OS === "ios";
const [titleAccent, mutedForeground, titleAccentForeground] = useCSSVariable([
const [titleAccent, mutedForeground, titleAccentForeground, destructiveColor] = useCSSVariable([
"--color-title-accent",
"--color-muted-foreground",
"--color-title-accent-foreground",
]) as [string, string, string];
"--color-destructive",
]) as [string, string, string, string];
const detail = useQuery(orpc.titles.get.queryOptions({ input: { id } }));
const userInfo = useQuery(orpc.tracking.userInfo.queryOptions({ input: { id } }));
const recommendations = useQuery(orpc.titles.similar.queryOptions({ input: { id } }));
const { updateStatus, updateRating, watchMovie } = useTitleActions({
const { updateStatus, updateRating, watchMovie, unwatchMovie } = useTitleActions({
toasts: {
watchMovie: () => {
const name = title?.title;
return name ? t`Marked "${name}" as watched` : t`Marked as watched`;
},
unwatchMovie: () => {
const name = title?.title;
return name ? t`Marked "${name}" as unwatched` : t`Marked as unwatched`;
},
},
});
@@ -147,6 +153,7 @@ export default function TitleDetailScreen() {
() => new Set(userInfo.data?.episodeWatches ?? []),
[userInfo.data?.episodeWatches],
);
const isMovieWatched = userInfo.data?.status === "completed";
const recItems = useMemo<PosterRowItem[]>(
() =>
@@ -413,17 +420,34 @@ export default function TitleDetailScreen() {
updateStatus.mutate({ id, status: null });
}
}}
isPending={updateStatus.isPending || watchMovie.isPending}
isPending={updateStatus.isPending || watchMovie.isPending || unwatchMovie.isPending}
/>
{title.type === "movie" && (
<Pressable
onPress={() => watchMovie.mutate({ scope: "movie", ids: [id] })}
disabled={watchMovie.isPending}
className="bg-title-accent flex-row items-center gap-1.5 rounded-lg px-4 py-2"
onPress={() => {
if (isMovieWatched) {
unwatchMovie.mutate({ scope: "movie", ids: [id] });
} else {
watchMovie.mutate({ scope: "movie", ids: [id] });
}
}}
disabled={watchMovie.isPending || unwatchMovie.isPending}
className={
isMovieWatched
? "border-destructive/20 bg-destructive/10 flex-row items-center gap-1.5 rounded-lg border px-4 py-2"
: "bg-title-accent flex-row items-center gap-1.5 rounded-lg px-4 py-2"
}
>
{watchMovie.isPending ? (
{watchMovie.isPending || unwatchMovie.isPending ? (
<Spinner size="sm" />
) : isMovieWatched ? (
<>
<ScaledIcon icon={IconX} size={16} color={destructiveColor} />
<Text className="text-destructive font-sans text-sm font-medium">
<Trans>Mark Unwatched</Trans>
</Text>
</>
) : (
<>
<ScaledIcon icon={IconCheck} size={16} color={titleAccentForeground} />
@@ -126,7 +126,14 @@ export function UpcomingRow({ item }: { item: UpcomingItem }) {
<Link.Trigger withAppleZoom>{rowContent}</Link.Trigger>
<Link.Preview />
<Link.Menu>
{item.titleType === "movie" && (
{item.titleType === "movie" && item.userStatus === "completed" && (
<Link.MenuAction
title={t`Mark Unwatched`}
icon="xmark.circle"
onPress={() => titleActions.unwatchMovie(item.titleId, item.titleName)}
/>
)}
{item.titleType === "movie" && item.userStatus !== "completed" && (
<Link.MenuAction
title={t`Mark Watched`}
icon="checkmark.circle"
@@ -217,7 +217,14 @@ export const PosterCard = memo(function PosterCard({
onPress={handleQuickAddPress}
/>
)}
{type === "movie" && (
{type === "movie" && userStatus === "completed" && (
<Link.MenuAction
title={t`Mark Unwatched`}
icon="xmark.circle"
onPress={() => titleActions.unwatchMovie(id, title)}
/>
)}
{type === "movie" && userStatus !== "completed" && (
<Link.MenuAction
title={t`Mark Watched`}
icon="checkmark.circle"
@@ -26,6 +26,7 @@ interface UseTitleActionsOptions {
toasts?: {
updateStatus?: ToastOverride<{ id: string; status: string | null }>;
watchMovie?: ToastOverride<WatchInput>;
unwatchMovie?: ToastOverride<WatchInput>;
updateRating?: ToastOverride<{ id: string; stars: number }>;
watchEpisode?: ToastOverride<WatchInput>;
unwatchEpisode?: ToastOverride<WatchInput>;
@@ -67,6 +68,16 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
}),
);
const unwatchMovie = useMutation(
orpc.tracking.unwatch.mutationOptions({
onSuccess: (_data, input) => {
toast.success(resolveToast(toastOverrides?.unwatchMovie, t`Marked as unwatched`, input));
invalidateTitleQueries();
},
onError: () => toast.error(t`Failed to mark as unwatched`),
}),
);
const updateRating = useMutation(
orpc.tracking.rate.mutationOptions({
onSuccess: (_data, input) => {
@@ -115,6 +126,7 @@ export function useTitleActions(options?: UseTitleActionsOptions) {
return {
updateStatus,
watchMovie,
unwatchMovie,
updateRating,
watchEpisode,
unwatchEpisode,
+14
View File
@@ -55,6 +55,20 @@ export const titleActions = {
}
},
async unwatchMovie(id: string, titleName?: string) {
try {
await client.tracking.unwatch({ scope: "movie", ids: [id] });
toast.success(
titleName
? i18n._(msg`Marked "${titleName}" as unwatched`)
: i18n._(msg`Marked as unwatched`),
);
invalidateTitleQueries();
} catch {
toast.error(i18n._(msg`Failed to mark as unwatched`));
}
},
async removeFromLibrary(id: string) {
try {
await client.tracking.updateStatus({ id, status: null });
@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro";
import { IconCheck } from "@tabler/icons-react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -12,19 +12,31 @@ import { useTitleActions } from "./use-title-actions";
export function TitleActions() {
const { titleType } = useTitleContext();
const { userStatus, userRating } = useTitleUserInfo();
const { handleStatusChange, handleRating, handleWatchMovie } = useTitleActions();
const { handleStatusChange, handleRating, handleWatchMovie, handleUnwatchMovie } =
useTitleActions();
const isMovieWatched = userStatus === "completed";
return (
<div className="flex flex-wrap items-center gap-3">
<StatusButton currentStatus={userStatus ?? null} onChange={handleStatusChange} />
{titleType === "movie" && (
<Button
onClick={handleWatchMovie}
onClick={isMovieWatched ? handleUnwatchMovie : handleWatchMovie}
variant={isMovieWatched ? "destructive" : "default"}
size="lg"
className="hover:shadow-primary/20 h-9 rounded-lg px-4 text-sm hover:shadow-md active:scale-[0.97]"
className={`h-9 rounded-lg px-4 text-sm hover:shadow-md active:scale-[0.97] ${isMovieWatched ? "hover:shadow-destructive/20" : "hover:shadow-primary/20"}`}
>
<IconCheck aria-hidden={true} className="size-3.5" />
<Trans>Mark Watched</Trans>
{isMovieWatched ? (
<>
<IconX aria-hidden={true} className="size-3.5" />
<Trans>Mark Unwatched</Trans>
</>
) : (
<>
<IconCheck aria-hidden={true} className="size-3.5" />
<Trans>Mark Watched</Trans>
</>
)}
</Button>
)}
<Separator orientation="vertical" className="bg-border/50 mx-0.5 my-auto h-6" />
@@ -9,7 +9,8 @@ import { useTitleActions } from "./use-title-actions";
export function TitleKeyboardShortcuts() {
const { titleType } = useTitleContext();
const { userStatus } = useTitleUserInfo();
const { handleStatusChange, handleRating, handleWatchMovie } = useTitleActions();
const { handleStatusChange, handleRating, handleWatchMovie, handleUnwatchMovie } =
useTitleActions();
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
const enabled = !commandPaletteOpen;
@@ -21,7 +22,13 @@ export function TitleKeyboardShortcuts() {
useHotkey(
"M",
() => {
if (titleType === "movie") handleWatchMovie();
if (titleType === "movie") {
if (userStatus === "completed") {
handleUnwatchMovie();
} else {
handleWatchMovie();
}
}
},
{ enabled },
);
@@ -135,6 +135,19 @@ export function useTitleActions() {
}
}, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, watch, t]);
const handleUnwatchMovie = useCallback(async () => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
const prevStatus = getUserInfo().status;
setUserInfo((old) => ({ ...old, status: "in_watchlist" }));
try {
await unwatch({ scope: "movie", ids: [titleId] });
toast.success(t`Marked "${titleName}" as unwatched`);
} catch {
setUserInfo((old) => ({ ...old, status: prevStatus }));
toast.error(t`Failed to mark as unwatched`);
}
}, [getUserInfo, setUserInfo, queryClient, userInfoKey, titleId, titleName, unwatch, t]);
const handleWatchEpisode = useCallback(
async (episodeId: string, seasonNum: number, epNum: number, isWatched: boolean) => {
await queryClient.cancelQueries({ queryKey: userInfoKey });
@@ -352,6 +365,7 @@ export function useTitleActions() {
handleStatusChange,
handleRating,
handleWatchMovie,
handleUnwatchMovie,
handleWatchEpisode,
handleMarkSeason,
handleUnmarkSeason,