diff --git a/web/app/globals.css b/web/app/globals.css index a7c4100f8..569bf8117 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -11960,3 +11960,189 @@ html { background: #000; } outline: 2px solid var(--accent, #fff); outline-offset: 2px; } + +/* ============================================================ + Context Menu (Right-click PC & Mobile long-press) + ============================================================ */ +.context-menu-scrim { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(12px); + animation: arvio-fade-in 180ms ease both; + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +@media (min-width: 768px) { + .context-menu-scrim { + justify-content: center; + align-items: center; + } +} + +.context-menu-card { + width: 100%; + max-width: 400px; + background: linear-gradient(180deg, rgba(30, 35, 45, 0.98), rgba(12, 14, 18, 0.99)); + border-top-left-radius: 20px; + border-top-right-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.7); + padding: 16px 16px 24px; + animation: arvio-slide-up 220ms cubic-bezier(0.16, 1, 0.3, 1) both; + overflow: hidden; +} + +.context-menu-card.is-floating { + border-radius: 16px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.12); + width: 320px; + padding: 16px; + animation: arvio-scale-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.context-menu-drag-handle { + width: 36px; + height: 4px; + background: rgba(255, 255, 255, 0.25); + border-radius: 2px; + margin: 0 auto 12px; +} + +.context-menu-card.is-floating .context-menu-drag-handle { + display: none; +} + +.context-menu-header-art { + position: relative; + width: calc(100% + 32px); + margin: -16px -16px 12px -16px; + height: 110px; + overflow: hidden; +} + +.context-menu-header-art img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.context-menu-header-art-gradient { + position: absolute; + inset: 0; + background: linear-gradient(180deg, transparent 0%, rgba(12, 14, 18, 0.95) 100%); +} + +.context-menu-header { + padding: 4px 8px 8px; +} + +.context-menu-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: #fff; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-menu-subtitle { + margin: 4px 0 0; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.6); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-menu-divider { + height: 1.5px; + background: rgba(255, 255, 255, 0.08); + margin: 8px 0; +} + +.context-menu-actions { + display: flex; + flex-direction: column; + gap: 4px; +} + +.context-menu-action-btn { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 14px; + background: transparent; + border: none; + border-radius: 10px; + color: rgba(255, 255, 255, 0.9); + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, transform 100ms ease; + text-align: left; +} + +.context-menu-action-btn:hover, +.context-menu-action-btn:focus-visible { + background: rgba(255, 255, 255, 0.1); + color: #fff; + outline: none; +} + +.context-menu-action-btn.is-danger { + color: #ef4444; +} + +.context-menu-action-btn.is-danger:hover, +.context-menu-action-btn.is-danger:focus-visible { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} + +.context-menu-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; +} + +.context-menu-label { + flex: 1; +} + +.context-menu-footer-hint { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.4); + text-align: center; +} + +.context-menu-card.is-floating .context-menu-footer-hint { + display: none; +} + +@keyframes arvio-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes arvio-slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes arvio-scale-in { + from { opacity: 0; transform: scale(0.92); } + to { opacity: 1; transform: scale(1); } +} diff --git a/web/components/details/DetailsDrawer.tsx b/web/components/details/DetailsDrawer.tsx index e2b8b7017..a037b76a6 100644 --- a/web/components/details/DetailsDrawer.tsx +++ b/web/components/details/DetailsDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { BadgeCheck, Bookmark, CalendarDays, Clapperboard, Copy, Download, ExternalLink, Filter, MapPin, Play, Search, Star, Trash2, UserCircle, X } from "lucide-react"; +import { BadgeCheck, Bookmark, CalendarDays, Check, Clapperboard, Copy, Download, ExternalLink, EyeOff, Filter, MapPin, Play, Search, Star, Trash2, UserCircle, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { MediaCard } from "@/components/media/MediaCard"; @@ -37,7 +37,7 @@ function needsDetailsHydration(item: MediaItem) { } function DetailsView({ item }: { item: MediaItem }) { - const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally } = useApp(); + const { streams, selectedEpisode, activeProfile, addons: installedAddons, loadEpisodeStreams, openDetails, playStream, playTrailer, setToast, settings, watchlist, refreshData, busy, isWatched, markWatchedLocally, openContextMenu, toggleWatched } = useApp(); const [detailsItem, setDetailsItem] = useState(item); const [detailsLoading, setDetailsLoading] = useState(false); const [reviews, setReviews] = useState([]); @@ -891,6 +891,7 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl isWatched: (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => boolean; onPlayEpisode: (season: number, episode: number) => void; }) { + const { openContextMenu, setToast, toggleWatched } = useApp(); const seasons = item.seasons ?? []; const [season, setSeason] = useState(seasons[0]?.seasonNumber ?? 1); const [episodes, setEpisodes] = useState([]); @@ -913,6 +914,77 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl return () => { active = false; }; }, [item.id, season, retryNonce]); + const updateSeasonWatched = async (seasonNum: number, watched: boolean) => { + try { + const targetEpisodes = await getSeasonEpisodes(item.id, seasonNum); + for (const ep of targetEpisodes) { + if (isWatched(item, seasonNum, ep.episodeNumber) !== watched) { + await toggleWatched(item, seasonNum, ep.episodeNumber); + } + } + setToast(`Season ${seasonNum} marked as ${watched ? "watched" : "unwatched"}.`); + } catch { + setToast(`Could not update Season ${seasonNum}. Please try again.`); + } + }; + + const handleSeasonContextMenu = (e: React.MouseEvent, seasonNum: number, seasonName: string) => { + e.preventDefault(); + e.stopPropagation(); + openContextMenu({ + title: seasonName || `Season ${seasonNum}`, + subtitle: item.title, + position: { x: e.clientX, y: e.clientY }, + actions: [ + { + id: "mark_season_watched", + label: "Mark Season Watched", + icon: , + action: async () => { + await updateSeasonWatched(seasonNum, true); + } + }, + { + id: "mark_season_unwatched", + label: "Mark Season Unwatched", + icon: , + action: async () => { + await updateSeasonWatched(seasonNum, false); + } + } + ] + }); + }; + + const handleEpisodeContextMenu = (e: React.MouseEvent, ep: EpisodeInfo) => { + e.preventDefault(); + e.stopPropagation(); + const watched = isWatched(item, season, ep.episodeNumber); + openContextMenu({ + title: ep.name || `Episode ${ep.episodeNumber}`, + subtitle: `${item.title} - S${season} E${ep.episodeNumber}`, + position: { x: e.clientX, y: e.clientY }, + actions: [ + { + id: "play_episode", + label: "Play Episode", + icon: , + action: () => { + onPlayEpisode(season, ep.episodeNumber); + } + }, + { + id: "toggle_episode_watched", + label: watched ? "Mark as Unwatched" : "Mark as Watched", + icon: watched ? : , + action: () => { + void toggleWatched(item, season, ep.episodeNumber); + } + } + ] + }); + }; + return (

Episodes

@@ -923,6 +995,7 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl key={s.id} className={`season-tab ${s.seasonNumber === season ? "is-active" : ""}`} onClick={() => setSeason(s.seasonNumber)} + onContextMenu={(e) => handleSeasonContextMenu(e, s.seasonNumber, s.name || `Season ${s.seasonNumber}`)} > {s.name || `Season ${s.seasonNumber}`} @@ -947,6 +1020,7 @@ function SeasonEpisodes({ item, loadingDetails, selectedEpisode, isWatched, onPl key={episode.id} className={`episode-row ${active ? "is-active" : ""} ${watched ? "is-watched" : ""}`} onClick={() => onPlayEpisode(season, episode.episodeNumber)} + onContextMenu={(e) => handleEpisodeContextMenu(e, episode)} >
{episode.still ? : } diff --git a/web/components/media/MediaCard.tsx b/web/components/media/MediaCard.tsx index c39ea8bfd..a3f865048 100644 --- a/web/components/media/MediaCard.tsx +++ b/web/components/media/MediaCard.tsx @@ -1,7 +1,7 @@ "use client"; import { BadgeCheck, Clapperboard } from "lucide-react"; -import { memo, useEffect, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { getImdbRating } from "@/lib/imdbRatings"; import { IMDB_LOGO, serviceClearLogo } from "@/lib/serviceLogos"; import { useApp } from "@/lib/store"; @@ -42,9 +42,10 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { onFocus?: (item: MediaItem) => void; posterMode?: boolean; }) { - const { settings, isWatched } = useApp(); + const { settings, isWatched, openContextMenu } = useApp(); const effectivePosterMode = posterMode ?? settings.cardLayoutMode === "poster"; const [logo, setLogo] = useState(null); + const [imgLoaded, setImgLoaded] = useState(false); const progress = item.progress ?? 0; const watched = isWatched(item); // "Up next" rows carry SERIES completion (how far through the show you are), @@ -53,6 +54,8 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { // "Up next" chip instead; the bar stays for genuinely resumable items. const isUpNext = item.timeRemainingLabel === "Up next"; const showProgress = !watched && !isUpNext && progress >= 1 && progress <= 94; + const longPressTimer = useRef | null>(null); + const suppressClickUntil = useRef(0); // CW/up-next items from Trakt arrive with no artwork, and a hydration that hit // a network/429 error leaves image+backdrop empty — the card renders grey while // the (separately cached) logo shows. Back-fill artwork lazily from TMDB. @@ -62,6 +65,65 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { const artwork = effectivePosterMode ? (image || backdrop) : (backdrop || image); const year = item.releaseDate?.slice(0, 4) || item.year || (item.mediaType === "tv" ? "Series" : "Movie"); + useEffect(() => { + setImgLoaded(false); + }, [artwork]); + + const triggerContextMenu = (posX?: number, posY?: number) => { + openContextMenu({ + item, + isContinueWatching: isUpNext || showProgress || Boolean(item.timeRemainingLabel), + position: posX !== undefined && posY !== undefined ? { x: posX, y: posY } : null + }); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + triggerContextMenu(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + const touch = e.touches[0]; + const posX = touch?.clientX ?? 0; + const posY = touch?.clientY ?? 0; + longPressTimer.current = setTimeout(() => { + suppressClickUntil.current = Date.now() + 750; + triggerContextMenu(posX, posY); + }, 500); + }; + + const handleTouchEnd = () => { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + }; + + const handleClick = (e: React.MouseEvent) => { + if (Date.now() < suppressClickUntil.current) { + e.preventDefault(); + e.stopPropagation(); + return; + } + onOpen(item); + }; + + const hoverTimer = useRef | null>(null); + + const handleMouseEnter = () => { + if (hoverTimer.current) clearTimeout(hoverTimer.current); + hoverTimer.current = setTimeout(() => { + prefetchDetails(item); + onFocus?.(item); + }, 120); + }; + + const handleMouseLeave = () => { + if (hoverTimer.current) { + clearTimeout(hoverTimer.current); + hoverTimer.current = null; + } + }; + // Rails load lazily, so a card only mounts when its row is near the viewport — // fetch the title-treatment logo on mount (getLogoUrl is cached + persisted). useEffect(() => { @@ -125,12 +187,28 @@ function MediaCardBase({ item, onOpen, onFocus, posterMode }: { + ))} +
+ +
+ Press ESC to close +
+ + + ); +} diff --git a/web/lib/cloud.ts b/web/lib/cloud.ts index ff694d712..51f9bbc90 100644 --- a/web/lib/cloud.ts +++ b/web/lib/cloud.ts @@ -1070,6 +1070,46 @@ export async function saveProgress(auth: AuthClient, entry: Omit, + profileId?: string | null +) { + if (!auth.session) return; + const matches = (candidate: AndroidContinueWatchingItem) => { + if (candidate.id !== item.id || String(candidate.mediaType ?? "").toLowerCase() !== item.mediaType) return false; + if (item.mediaType !== "tv") return true; + if (item.seasonNumber != null && candidate.season !== item.seasonNumber) return false; + if (item.episodeNumber != null && candidate.episode !== item.episodeNumber) return false; + return true; + }; + + if (canUseBackendSync(auth)) { + await mutateCloudPayload(auth, (root) => { + const targetProfileId = profileId ?? "default"; + for (const key of ["localContinueWatchingByProfile", "continueWatchingByProfile"]) { + const byProfile = objectRecord(root[key]); + byProfile[targetProfileId] = arrayValue(byProfile[targetProfileId]).filter((candidate) => !matches(candidate)); + root[key] = byProfile; + } + for (const key of ["localContinueWatching", "continueWatching"]) { + root[key] = arrayValue(root[key]).filter((candidate) => !matches(candidate)); + } + }); + return; + } + + const query = new URLSearchParams({ + user_id: `eq.${auth.session.userId}`, + media_type: `eq.${item.mediaType}`, + show_tmdb_id: `eq.${item.id}` + }); + query.set("profile_id", profileId ? `eq.${profileId}` : "is.null"); + if (item.seasonNumber != null) query.set("season", `eq.${item.seasonNumber}`); + if (item.episodeNumber != null) query.set("episode", `eq.${item.episodeNumber}`); + await auth.supabase(`/rest/v1/watch_history?${query.toString()}`, { method: "DELETE" }); +} + export async function markWatched(auth: AuthClient, entry: Omit, profileId?: string | null) { await saveProgress(auth, { diff --git a/web/lib/mdblist.ts b/web/lib/mdblist.ts index 620715203..5976ec361 100644 --- a/web/lib/mdblist.ts +++ b/web/lib/mdblist.ts @@ -183,6 +183,10 @@ export class MdbListClient { await this.request("sync/watched/remove", { method: "POST", body: JSON.stringify(this.watchedBody(item)) }); } + async dismissFromContinueWatching(_item: MdbMediaRef) { + // MDBList does not expose paused-playback dismissal. Keep watched history intact. + } + private watchedBody(item: MdbMediaRef) { if (item.mediaType === "tv") { const ids = { tmdb: item.tmdbId }; diff --git a/web/lib/store.tsx b/web/lib/store.tsx index 7feb39d75..12cd9961f 100644 --- a/web/lib/store.tsx +++ b/web/lib/store.tsx @@ -5,7 +5,7 @@ import { getStreams, getStreamsProgressive, installAddon as installAddonManifest import { AuthClient, SESSION_KEY, decodeJwtPayload } from "./auth"; import { getAuthPortalUrl } from "./config"; import { defaultCatalogs, mergeCatalogs } from "./catalogs"; -import { getContinueWatching, isLiveStreamOrSportsItem, pullCloudPayload, pullCloudProfiles, pullCloudTraktToken, pullCloudWatchlist, saveCloudAddons, saveCloudProfiles, saveCloudSettings, saveCloudTraktToken } from "./cloud"; +import { getContinueWatching, isLiveStreamOrSportsItem, pullCloudPayload, pullCloudProfiles, pullCloudTraktToken, pullCloudWatchlist, removeContinueWatchingProgress, saveCloudAddons, saveCloudProfiles, saveCloudSettings, saveCloudTraktToken, saveProgress } from "./cloud"; import { cachedDebridDirectUrl, parseDebridStream, resolveDebridDirectUrl, resolveTranscodeStream } from "./debrid"; import { createPendingExternalPlayback } from "./externalPlayback"; import { externalLaunchMode, openExternalPlayer } from "./externalPlayers"; @@ -508,6 +508,28 @@ export interface AppStore { // Watchlist list-source switcher (Trakt custom lists / collection). loadTraktLists: () => Promise>; loadTraktListItems: (source: string) => Promise; + + toggleWatchlist: (item: MediaItem) => Promise; + toggleWatched: (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => Promise; + removeFromContinueWatching: (item: MediaItem) => Promise; + activeContextMenu: ContextMenuTarget | null; + openContextMenu: (target: ContextMenuTarget) => void; + closeContextMenu: () => void; +} + +export interface ContextMenuTarget { + item?: MediaItem; + title?: string; + subtitle?: string; + isContinueWatching?: boolean; + position?: { x: number; y: number } | null; + actions?: Array<{ + id: string; + label: string; + icon: React.ReactNode; + danger?: boolean; + action: () => void | Promise; + }>; } const AppContext = createContext(null); @@ -1732,6 +1754,124 @@ export function AppProvider({ }, []); const backToProfiles = useCallback(() => setView("profiles"), []); + const [activeContextMenu, setActiveContextMenu] = useState(null); + + const openContextMenu = useCallback((target: ContextMenuTarget) => { + setActiveContextMenu(target); + }, []); + + const closeContextMenu = useCallback(() => { + setActiveContextMenu(null); + }, []); + + const toggleWatchlist = useCallback(async (item: MediaItem) => { + const inWatchlist = watchlist.some((entry) => entry.mediaType === item.mediaType && entry.id === item.id); + if (activeSyncProvider() === "none") { + setToast("Connect Trakt or MDBList in Settings to use Watchlist."); + return; + } + const slim = slimCacheItem(item); + const cacheKey = watchlistCacheKeyFor(activeProfileId); + + setWatchlist((prev) => { + const next = inWatchlist + ? prev.filter((entry) => !(entry.mediaType === item.mediaType && entry.id === item.id)) + : [slim, ...prev]; + saveCachedList(cacheKey, next, 60); + return next; + }); + + try { + if (inWatchlist) { + await syncClient().removeFromWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); + setToast("Removed from watchlist."); + } else { + await syncClient().addToWatchlist({ mediaType: item.mediaType, tmdbId: item.id }); + setToast("Added to watchlist."); + } + } catch (err) { + setWatchlist((prev) => { + const next = inWatchlist ? [slim, ...prev] : prev.filter((entry) => !(entry.mediaType === item.mediaType && entry.id === item.id)); + saveCachedList(cacheKey, next, 60); + return next; + }); + setToast(err instanceof Error ? err.message : "Failed to update watchlist."); + } + }, [watchlist, activeProfileId]); + + const toggleWatched = useCallback(async (item: MediaItem, seasonNumber?: number | null, episodeNumber?: number | null) => { + const currentlyWatched = isWatched(item, seasonNumber, episodeNumber); + markWatchedLocally({ mediaType: item.mediaType, id: item.id, season: seasonNumber, episode: episodeNumber }, !currentlyWatched); + setToast(!currentlyWatched ? "Marked as watched." : "Marked as unwatched."); + + if (authClient.session) { + try { + await saveProgress( + authClient, + { + media_type: item.mediaType, + show_tmdb_id: item.id, + season: seasonNumber ?? item.seasonNumber ?? null, + episode: episodeNumber ?? item.episodeNumber ?? null, + title: item.title, + duration_seconds: 0, + position_seconds: 0, + progress: currentlyWatched ? 0 : 1 + }, + activeProfileId + ); + } catch { + // Cloud sync best effort + } + } + + if (activeSyncProvider() !== "none") { + try { + const ref = { mediaType: item.mediaType, tmdbId: item.id, season: seasonNumber, episode: episodeNumber }; + if (!currentlyWatched) { + await syncClient().addToHistory(ref); + } else { + await syncClient().removeFromHistory(ref); + } + } catch { + // Sync best effort + } + } + }, [isWatched, markWatchedLocally, authClient, activeProfileId]); + + const removeFromContinueWatching = useCallback(async (item: MediaItem) => { + const key = mediaWatchKey(item); + const cacheKey = cwCacheKeyFor(activeProfileId); + + setContinueWatching((prev) => { + const next = prev.filter((entry) => mediaWatchKey(entry) !== key && mediaWatchKey(entry) !== `${item.mediaType}:${item.id}`); + saveCachedList(cacheKey, next, 30); + return next; + }); + + setCategories((prev) => prev.map((cat) => cat.id === "continue_watching" + ? { ...cat, items: cat.items.filter((entry) => mediaWatchKey(entry) !== key && mediaWatchKey(entry) !== `${item.mediaType}:${item.id}`) } + : cat).filter((cat) => cat.id !== "continue_watching" || cat.items.length)); + + setToast("Removed from Continue Watching."); + + if (authClient.session) { + try { + await removeContinueWatchingProgress(authClient, item, activeProfileId); + } catch { + // Cloud sync best effort + } + } + + if (activeSyncProvider() !== "none") { + try { + await syncClient().dismissFromContinueWatching({ mediaType: item.mediaType, tmdbId: item.id, season: item.seasonNumber, episode: item.episodeNumber }); + } catch { + // Sync best effort + } + } + }, [activeProfileId, authClient]); + const value = useMemo(() => ({ view, cloudLoginRequired, @@ -1803,7 +1943,13 @@ export function AppProvider({ connectMdblist, disconnectMdblist, loadTraktLists, - loadTraktListItems + loadTraktListItems, + toggleWatchlist, + toggleWatched, + removeFromContinueWatching, + activeContextMenu, + openContextMenu, + closeContextMenu }), [ view, cloudLoginRequired, profiles, activeProfile, avatarImages, manageMode, selectProfile, createProfile, updateProfileAction, deleteProfileAction, switchProfile, goToLogin, backToProfiles, @@ -1813,7 +1959,8 @@ export function AppProvider({ refreshIptv, loadIptvGuide, installAddon, removeAddon, setAddonsState, signIn, signOut, beginTrakt, pollTrakt, disconnectTrakt, connectMdblist, disconnectMdblist, - loadTraktLists, loadTraktListItems + loadTraktLists, loadTraktListItems, + toggleWatchlist, toggleWatched, removeFromContinueWatching, activeContextMenu, openContextMenu, closeContextMenu ]); return {children}; diff --git a/web/lib/sync.ts b/web/lib/sync.ts index f4a08a1f9..530c016c1 100644 --- a/web/lib/sync.ts +++ b/web/lib/sync.ts @@ -23,6 +23,7 @@ export interface SyncClient { removeFromWatchlist(item: SyncMediaRef): Promise; addToHistory(item: SyncMediaRef): Promise; removeFromHistory(item: SyncMediaRef): Promise; + dismissFromContinueWatching(item: SyncMediaRef): Promise; scrobble(action: "start" | "pause" | "stop", item: SyncMediaRef & { progress: number }): Promise; } diff --git a/web/lib/trakt.ts b/web/lib/trakt.ts index 883cea6ea..108d6b14c 100644 --- a/web/lib/trakt.ts +++ b/web/lib/trakt.ts @@ -242,6 +242,41 @@ export class TraktClient { }); } + async dismissFromContinueWatching(item: TraktMediaRef) { + if (!this.token) return; + await this.refreshIfNeeded(); + + const playbackRows = await this.playback(); + const matchingPlaybackIds = playbackRows.flatMap((raw) => { + const row = raw as { + id?: number; + movie?: { ids?: { tmdb?: number } }; + show?: { ids?: { tmdb?: number } }; + episode?: { season?: number; number?: number }; + }; + const sameTitle = item.mediaType === "movie" + ? row.movie?.ids?.tmdb === item.tmdbId + : row.show?.ids?.tmdb === item.tmdbId; + const sameEpisode = item.mediaType !== "tv" || + ((item.season == null || row.episode?.season === item.season) && + (item.episode == null || row.episode?.number === item.episode)); + return sameTitle && sameEpisode && typeof row.id === "number" ? [row.id] : []; + }); + + await Promise.all(matchingPlaybackIds.map((id) => this.trakt(`/sync/playback/${id}`, { + method: "DELETE", + headers: { "x-user-token": this.token!.access_token } + }))); + + if (item.mediaType === "tv") { + await this.trakt("/users/hidden/progress_watched", { + method: "POST", + headers: { "x-user-token": this.token.access_token }, + body: JSON.stringify({ shows: [{ ids: { tmdb: item.tmdbId } }] }) + }); + } + } + async removeFromWatchlist(item: TraktMediaRef) { if (!this.token) return; await this.refreshIfNeeded();