diff --git a/backend/alembic/versions/0012_watch_later.py b/backend/alembic/versions/0012_watch_later.py new file mode 100644 index 0000000..fd5a847 --- /dev/null +++ b/backend/alembic/versions/0012_watch_later.py @@ -0,0 +1,65 @@ +"""unify Saved into a built-in Watch later playlist + +Revision ID: 0012_watch_later +Revises: 0011_playlists +Create Date: 2026-06-15 + +Migrates the old per-video status='saved' into membership of a built-in 'watch_later' +playlist (one per user who had saved videos), then demotes those states to 'new'. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0012_watch_later" +down_revision: Union[str, None] = "0011_playlists" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1) A Watch later playlist for each user that has saved videos (and none yet). + op.execute( + """ + INSERT INTO playlists (user_id, name, kind, source, dirty, position, created_at, updated_at) + SELECT DISTINCT vs.user_id, 'Watch later', 'watch_later', 'local', false, 0, now(), now() + FROM video_states vs + WHERE vs.status = 'saved' + AND NOT EXISTS ( + SELECT 1 FROM playlists p + WHERE p.user_id = vs.user_id AND p.kind = 'watch_later' + ) + """ + ) + # 2) Add each saved video as an item, ordered (newest-saved first → position 0). + op.execute( + """ + INSERT INTO playlist_items (playlist_id, video_id, position, added_at) + SELECT p.id, vs.video_id, + row_number() OVER ( + PARTITION BY vs.user_id + ORDER BY COALESCE(vs.watched_at, vs.updated_at) DESC + ) - 1, + COALESCE(vs.watched_at, vs.updated_at, now()) + FROM video_states vs + JOIN playlists p ON p.user_id = vs.user_id AND p.kind = 'watch_later' + WHERE vs.status = 'saved' + ON CONFLICT (playlist_id, video_id) DO NOTHING + """ + ) + # 3) Demote the migrated saved states to the default. + op.execute("UPDATE video_states SET status = 'new' WHERE status = 'saved'") + + +def downgrade() -> None: + # Best-effort reverse: restore status='saved' for videos in watch_later lists, then drop them. + op.execute( + """ + UPDATE video_states vs SET status = 'saved' + FROM playlist_items pi + JOIN playlists p ON p.id = pi.playlist_id AND p.kind = 'watch_later' + WHERE pi.video_id = vs.video_id AND p.user_id = vs.user_id + AND vs.status = 'new' + """ + ) + op.execute("DELETE FROM playlists WHERE kind = 'watch_later'") diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index b743b92..0ef733b 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased from app import quota from app.auth import current_user from app.db import get_db -from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState +from app.models import ( + Channel, + ChannelTag, + Playlist, + PlaylistItem, + Subscription, + Tag, + User, + Video, + VideoState, +) from app.sync.videos import parse_iso8601_duration from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) -VALID_STATES = {"new", "watched", "saved", "hidden"} +# "saved" used to be a status; it's now membership in the built-in Watch later playlist. +VALID_STATES = {"new", "watched", "hidden"} HIDDEN_LIVE = ("live", "upcoming") # Resume-position thresholds (mirror the client): positions below this are "didn't @@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str: return f"https://www.youtube.com/channel/{channel_id}" +def _saved_expr(user: User): + """A correlated EXISTS labelled `saved`: is this video in the user's Watch later list? + (Watch later is the built-in playlist that replaced the old per-video 'saved' status.)""" + return ( + select(1) + .select_from(PlaylistItem) + .join(Playlist, Playlist.id == PlaylistItem.playlist_id) + .where( + Playlist.user_id == user.id, + Playlist.kind == "watch_later", + PlaylistItem.video_id == Video.id, + ) + .exists() + .label("saved") + ) + + def _serialize(row) -> dict: return { "id": row.id, @@ -45,6 +73,7 @@ def _serialize(row) -> dict: "live_status": row.live_status, "status": row.status or "new", "position_seconds": row.position_seconds or 0, + "saved": bool(getattr(row, "saved", False)), "watch_url": f"https://www.youtube.com/watch?v={row.id}", } @@ -96,6 +125,7 @@ def _filtered_query( Video.live_status, status_expr.label("status"), position_expr.label("position_seconds"), + _saved_expr(user), ).join(Channel, Channel.id == Video.channel_id) if scope == "all": @@ -176,7 +206,7 @@ def _filtered_query( # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # Explicit Watched/Saved/Hidden views show every type so nothing goes missing. - explicit_view = show in ("watched", "saved", "hidden") + explicit_view = show in ("watched", "hidden") if not explicit_view: type_clauses = [] if show_normal: @@ -198,8 +228,6 @@ def _filtered_query( ) elif show == "watched": query = query.where(status_expr == "watched") - elif show == "saved": - query = query.where(status_expr == "saved") elif show == "hidden": query = query.where(status_expr == "hidden") else: # all diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index ba90de3..885cd4f 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, aliased from app.auth import current_user from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState -from app.routes.feed import _serialize +from app.routes.feed import _saved_expr, _serialize router = APIRouter(prefix="/api/playlists", tags=["playlists"]) @@ -110,6 +110,83 @@ def create_playlist( return _summary(db, pl, 0) +def _watch_later(db: Session, user: User) -> Playlist: + """The user's built-in Watch later playlist, created on first use.""" + pl = db.scalar( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.kind == "watch_later" + ) + ) + if pl is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user.id + ) + ) + + 1 + ) + pl = Playlist( + user_id=user.id, name="Watch later", kind="watch_later", position=next_pos + ) + db.add(pl) + db.commit() + return pl + + +@router.post("/watch-later") +def add_watch_later( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Bookmark shortcut: add a video to the built-in Watch later playlist.""" + video_id = payload.get("video_id") + if not video_id or db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + pl = _watch_later(db, user) + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == pl.id + ) + ) + + 1 + ) + db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)) + db.commit() + return {"saved": True, "playlist_id": pl.id} + + +@router.delete("/watch-later/{video_id}") +def remove_watch_later( + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = db.scalar( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.kind == "watch_later" + ) + ) + if pl is not None: + item = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if item is not None: + db.delete(item) + db.commit() + return {"saved": False} + + @router.get("/{playlist_id}") def get_playlist( playlist_id: int, @@ -134,6 +211,7 @@ def get_playlist( Video.live_status, func.coalesce(state.status, "new").label("status"), func.coalesce(state.position_seconds, 0).label("position_seconds"), + _saved_expr(user), ) .join(PlaylistItem, PlaylistItem.video_id == Video.id) .join(Channel, Channel.id == Video.channel_id) diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 637bb34..ab27f28 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -165,7 +165,9 @@ export default function AddToPlaylist({ > {pl.has_video && } - {pl.name} + + {pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name} + ))} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7380bb0..9133f7b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean { return status === "hidden"; case "watched": return status === "watched"; - case "saved": - return status === "saved"; case "unwatched": case "in_progress": // (in_progress is further narrowed server-side by resume position; here we only @@ -42,6 +40,7 @@ export default function Feed({ }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); + const [savedOverrides, setSavedOverrides] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( null @@ -64,8 +63,14 @@ export default function Feed({ // arrives — after a refetch the server is authoritative, so a stale override // (e.g. a video reverted to "new" from the notification center) won't keep it // filtered out of the current view. - useEffect(() => setOverrides({}), [filters]); - useEffect(() => setOverrides({}), [query.dataUpdatedAt]); + useEffect(() => { + setOverrides({}); + setSavedOverrides({}); + }, [filters]); + useEffect(() => { + setOverrides({}); + setSavedOverrides({}); + }, [query.dataUpdatedAt]); const countQuery = useQuery({ queryKey: ["feed-count", filters], @@ -139,10 +144,24 @@ export default function Feed({ [filters, setFilters] ); + const onToggleSave = useCallback( + (id: string, saved: boolean) => { + setSavedOverrides((o) => ({ ...o, [id]: saved })); + (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) + .then(() => { + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + }) + .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); + }, + [qc] + ); + const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = loaded; const items: Video[] = loaded .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) + .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); if (query.isLoading) return
{t("feed.loading")}
; @@ -192,6 +211,7 @@ export default function Feed({ video={v} view="grid" onState={onState} + onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} onOpen={openVideo} /> @@ -205,6 +225,7 @@ export default function Feed({ video={v} view="list" onState={onState} + onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} onOpen={openVideo} /> diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 50ff483..0448c15 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; +import { ArrowLeft, Check, CheckCheck, SkipBack, SkipForward, X } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; @@ -184,6 +184,8 @@ function loadYouTubeApi(): Promise { export default function PlayerModal({ video, startAt, + queue, + startIndex, onClose, onState, }: { @@ -191,30 +193,47 @@ export default function PlayerModal({ // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // to resume from the server-saved position carried on the video. startAt?: number | null; + // Optional playback queue (e.g. a playlist). When present, the player advances through + // it (auto-advance on end + prev/next), recreating per item to reuse the single-video + // resume / auto-watch / progress logic. + queue?: Video[]; + startIndex?: number; onClose: () => void; onState: (id: string, status: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); - // Resume point for the video we opened with. - const resumeAt = startAt != null ? startAt : video.position_seconds || 0; + // Track the playing item by id (not a frozen index) so it survives the queue changing + // under us — e.g. an item removed in another tab. The index is derived from the *live* + // queue, so the N / M counter and prev/next neighbours stay correct without disrupting + // playback of the current video. + const [playingId, setPlayingId] = useState( + queue?.[startIndex ?? 0]?.id ?? video.id + ); + let index = queue ? queue.findIndex((v) => v.id === playingId) : 0; + if (index < 0) index = 0; + const active = queue && queue[index] ? queue[index] : video; + const hasQueue = !!queue && queue.length > 1; + // startAt only applies to the item we opened with; advanced items resume their own pos. + const resumeAt = + startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); const autoMarkedRef = useRef(false); // The player can navigate to other videos (YouTube links in a description). The - // currently-playing id may differ from the feed video we opened with. - const [currentVideoId, setCurrentVideoId] = useState(video.id); - const currentIdRef = useRef(video.id); - const navigated = currentVideoId !== video.id; + // currently-playing id may differ from the active item. + const [currentVideoId, setCurrentVideoId] = useState(active.id); + const currentIdRef = useRef(active.id); + const navigated = currentVideoId !== active.id; // Title/author of a navigated-to video, read from the player (free, no API call). const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); const loadVideo = (id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; - // Explicit start wins; otherwise resume the opened video, start others at 0. - const startSeconds = start != null ? start : id === video.id ? resumeAt : 0; + // Explicit start wins; otherwise resume the active item, start others at 0. + const startSeconds = start != null ? start : id === active.id ? resumeAt : 0; p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); currentIdRef.current = id; setCurrentVideoId(id); @@ -223,14 +242,22 @@ export default function PlayerModal({ // Local mirror of watch status so the toggle reacts instantly; changes are // propagated to the feed (and server) via onState. - const [status, setStatus] = useState(video.status); + const [status, setStatus] = useState(active.status); const watched = status === "watched"; const setWatched = (on: boolean) => { const next = on ? "watched" : "new"; setStatus(next); - onState(video.id, next); + onState(active.id, next); }; + // On queue advance (active changes), sync local state to the new item. + useEffect(() => { + setStatus(active.status); + setCurrentVideoId(active.id); + currentIdRef.current = active.id; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active.id]); + const seekTo = (seconds: number) => { const p = playerRef.current; if (p && typeof p.seekTo === "function") { @@ -292,10 +319,11 @@ export default function PlayerModal({ // auto-mark watched once playback reaches the end. useEffect(() => { let cancelled = false; - const id = video.id; + const id = active.id; + autoMarkedRef.current = false; - // Auto-watch only applies to the feed video we opened with — not to other - // videos the player navigated to via description links. + // Auto-watch only applies to the active item — not to other videos the player + // navigated to via description links. const maybeAutoWatch = (current: number, duration: number) => { if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (current > duration - FINISH_MARGIN) { @@ -342,10 +370,13 @@ export default function PlayerModal({ const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; if (d) setLiveData({ title: d.title, author: d.author }); } - // 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch). - if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) { - autoMarkedRef.current = true; - setWatched(true); + // 0 === ended → mark watched, then advance to the next queue item if any. + if (e?.data === 0 && currentIdRef.current === id) { + if (!autoMarkedRef.current) { + autoMarkedRef.current = true; + setWatched(true); + } + if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id); } }, }, @@ -358,10 +389,11 @@ export default function PlayerModal({ return () => { cancelled = true; window.clearInterval(timer); - // Final flush, then refresh the feed so the card's resume bar reflects this session. - Promise.resolve(persist()).finally(() => - qc.invalidateQueries({ queryKey: ["feed"] }) - ); + // Final flush, then refresh the feed + playlists so resume bars reflect this session. + Promise.resolve(persist()).finally(() => { + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + }); const p = playerRef.current; if (p && typeof p.destroy === "function") { try { @@ -373,7 +405,7 @@ export default function PlayerModal({ playerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [video.id]); + }, [active.id]); return (
+ {hasQueue && ( +
+ + + {index + 1} / {queue!.length} + + +
+ )} +
{/* Title row — title (with hover description) on the left, Close on the right. */}
@@ -401,12 +455,12 @@ export default function PlayerModal({ onMouseEnter={openDesc} onMouseLeave={scheduleCloseDesc} > - {navigated ? liveData?.title ?? t("player.loading") : video.title} + {navigated ? liveData?.title ?? t("player.loading") : active.title} {navigated && ( +

{plName(detail)}

+ {!builtin && ( + + )}
)}
@@ -305,18 +320,20 @@ export default function Playlists() {
- + {!builtin && ( + + )}
@@ -344,7 +361,7 @@ export default function Playlists() { key={v.id} video={v} index={i} - onPlay={() => setActive({ video: v, startAt: null })} + onPlay={() => setPlayingIndex(i)} onRemove={() => removeItem(v.id)} /> ))} @@ -356,11 +373,13 @@ export default function Playlists() { )} - {active && ( + {playingIndex != null && items[playingIndex] && ( setActive(null)} + video={items[playingIndex]} + queue={items} + startIndex={playingIndex} + startAt={null} + onClose={() => setPlayingIndex(null)} onState={playerState} /> )} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 95a8980..e846ba9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ const SORT_IDS = [ "shuffle", ]; -const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"]; +const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; // Fresh shuffle token for the "surprise me" sort. const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 2e5c368..0175365 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -20,7 +20,12 @@ export default function SyncStatus({ const { data } = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus, + // Track the scheduler near-real-time: poll even when the tab is backgrounded, and + // refresh immediately on tab focus (the global default disables focus refetch), so the + // "N without full history" count keeps ticking down without a manual reload. refetchInterval: 30_000, + refetchIntervalInBackground: true, + refetchOnWindowFocus: true, staleTime: 25_000, }); diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 489b14f..597470c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -19,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format"; function Actions({ video, onState, + onToggleSave, onChannelFilter, }: { video: Video; onState: (id: string, status: string) => void; + onToggleSave: (id: string, saved: boolean) => void; onChannelFilter?: (channelId: string, channelName: string) => void; }) { const { t } = useTranslation(); @@ -31,6 +33,11 @@ function Actions({ e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); }; + const toggleSave = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onToggleSave(video.id, !video.saved); + }; return (
- + ); } @@ -295,7 +304,7 @@ function VideoCard({ {video.channel_title}
{meta}
- + diff --git a/frontend/src/i18n/locales/de/player.json b/frontend/src/i18n/locales/de/player.json index 5daa6f3..1acf7b8 100644 --- a/frontend/src/i18n/locales/de/player.json +++ b/frontend/src/i18n/locales/de/player.json @@ -2,6 +2,8 @@ "loading": "Wird geladen…", "backToOriginal": "Zurück zum ursprünglichen Video", "back": "Zurück", + "previous": "Vorheriges", + "next": "Nächstes", "close": "Schließen", "closeEsc": "Schließen (Esc)", "description": "Beschreibung", diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 42f11b5..6d4851f 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -1,5 +1,6 @@ { "title": "Wiedergabelisten", + "watchLater": "Später ansehen", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", "itemCount_one": "{{count}} Video", diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json index a3d0ae2..aba13f2 100644 --- a/frontend/src/i18n/locales/en/player.json +++ b/frontend/src/i18n/locales/en/player.json @@ -2,6 +2,8 @@ "loading": "Loading…", "backToOriginal": "Back to the original video", "back": "Back", + "previous": "Previous", + "next": "Next", "close": "Close", "closeEsc": "Close (Esc)", "description": "Description", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 9aefd88..9bef503 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -1,5 +1,6 @@ { "title": "Playlists", + "watchLater": "Watch later", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", "itemCount_one": "{{count}} video", diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json index 96c0e81..c0e1ec2 100644 --- a/frontend/src/i18n/locales/hu/player.json +++ b/frontend/src/i18n/locales/hu/player.json @@ -2,6 +2,8 @@ "loading": "Betöltés…", "backToOriginal": "Vissza az eredeti videóhoz", "back": "Vissza", + "previous": "Előző", + "next": "Következő", "close": "Bezárás", "closeEsc": "Bezárás (Esc)", "description": "Leírás", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 2aa48ed..dcfcedd 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,5 +1,6 @@ { "title": "Lejátszási listák", + "watchLater": "Megnézem később", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", "itemCount_one": "{{count}} videó", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3908a87..f41535c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,6 +46,7 @@ export interface Video { live_status: string; status: string; position_seconds: number; + saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; } @@ -335,6 +336,14 @@ export const api = { method: "PUT", body: JSON.stringify({ video_ids: videoIds }), }), + // Bookmark shortcut for the built-in Watch later playlist. + watchLaterAdd: (videoId: string) => + req("/api/playlists/watch-later", { + method: "POST", + body: JSON.stringify({ video_id: videoId }), + }), + watchLaterRemove: (videoId: string) => + req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"),