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. */}