From fcb77ac2e1d5fc559a7a534cb293d08a9f775b29 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 12 Jun 2026 17:39:01 +0200 Subject: [PATCH] feat(player): watched controls, compact layout, description popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Watched: an explicit toggle in the modal (Mark watched / Watched→unmark) plus auto-mark when playback reaches the end (within 10s, or on the ended event). - Compact layout: drop the header bar and the redundant 'Open on YouTube' button (the embed's own YouTube logo already jumps out); Close moves to the title row, channel + meta share one line — fits without a scrollbar at higher zoom. - Card actions reflect status: watched shows a double-check, saved a filled bookmark, with matching tooltips. - Description: new GET /api/videos/{id} exposes the already-stored description, shown in a popover when hovering the modal title (fetched lazily). --- backend/app/routes/feed.py | 18 +++ frontend/src/components/Feed.tsx | 6 +- frontend/src/components/PlayerModal.tsx | 176 ++++++++++++++++-------- frontend/src/components/VideoCard.tsx | 14 +- frontend/src/lib/api.ts | 7 + 5 files changed, 161 insertions(+), 60 deletions(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 2fc5044..87bf28c 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -287,3 +287,21 @@ def set_video_state( row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at db.commit() return {"video_id": video_id, "status": status} + + +@router.get("/videos/{video_id}") +def get_video_detail( + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """On-demand detail (description, like count) — kept out of the feed list payload + so the feed stays lean; fetched lazily, e.g. for the title hover popover.""" + v = db.get(Video, video_id) + if v is None: + raise HTTPException(status_code=404, detail="Unknown video") + return { + "id": v.id, + "description": v.description, + "like_count": v.like_count, + } diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index b5109a4..e157664 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -139,7 +139,11 @@ export default function Feed({ )} {activeVideo && ( - setActiveVideo(null)} /> + setActiveVideo(null)} + onState={onState} + /> )}
{isFetchingNextPage && ( diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 04c3003..d91e56c 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,13 +1,17 @@ -import { useEffect, useRef } from "react"; -import { ExternalLink, X } from "lucide-react"; -import type { Video } from "../lib/api"; +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Check, CheckCheck, X } from "lucide-react"; +import { api, type Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position -// and resume where the user left off. The modal closes via the header X button, -// the backdrop, or ESC (ESC only while focus is on our page, not inside the -// cross-origin player iframe — a browser security boundary we can't cross). +// and resume where the user left off. The modal closes via the in-card Close +// button, the backdrop, or ESC (ESC only while focus is on our page, not inside +// the cross-origin player iframe — a browser security boundary we can't cross). + +// How close to the end (seconds) counts as "finished" → auto-mark watched. +const FINISH_MARGIN = 10; // --- IFrame Player API loader (singleton) --- let apiPromise: Promise | null = null; @@ -32,7 +36,7 @@ function loadYouTubeApi(): Promise { const posKey = (id: string) => `subfeed:player-pos:${id}`; function savePos(id: string, seconds: number, duration: number): void { // Don't store trivially-early or near-finished positions (start fresh next time). - if (!Number.isFinite(seconds) || seconds < 5 || (duration > 0 && seconds > duration - 10)) { + if (!Number.isFinite(seconds) || seconds < 5 || (duration > 0 && seconds > duration - FINISH_MARGIN)) { localStorage.removeItem(posKey(id)); return; } @@ -47,12 +51,34 @@ function loadPos(id: string): number { export default function PlayerModal({ video, onClose, + onState, }: { video: Video; onClose: () => void; + onState: (id: string, status: string) => void; }) { const mountRef = useRef(null); const playerRef = useRef(null); + const autoMarkedRef = useRef(false); + + // 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 watched = status === "watched"; + const setWatched = (on: boolean) => { + const next = on ? "watched" : "new"; + setStatus(next); + onState(video.id, next); + }; + + // Lazy description (fetched only when the title is hovered). + const [showDesc, setShowDesc] = useState(false); + const detail = useQuery({ + queryKey: ["video-detail", video.id], + queryFn: () => api.videoDetail(video.id), + enabled: showDesc, + staleTime: 5 * 60_000, + }); // ESC + background scroll lock. useEffect(() => { @@ -68,15 +94,27 @@ export default function PlayerModal({ }; }, [onClose]); - // Create the player, resume from the saved position, and persist progress. + // Create the player, resume from the saved position, persist progress, and + // auto-mark watched once playback reaches the end. useEffect(() => { let cancelled = false; const id = video.id; + + const maybeAutoWatch = (current: number, duration: number) => { + if (autoMarkedRef.current || duration <= 0) return; + if (current > duration - FINISH_MARGIN) { + autoMarkedRef.current = true; + setWatched(true); + } + }; const persist = () => { const p = playerRef.current; if (!p || typeof p.getCurrentTime !== "function") return; try { - savePos(id, p.getCurrentTime(), typeof p.getDuration === "function" ? p.getDuration() : 0); + const cur = p.getCurrentTime(); + const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; + maybeAutoWatch(cur, dur); + savePos(id, cur, dur); } catch { /* player may be tearing down */ } @@ -96,10 +134,19 @@ export default function PlayerModal({ origin: window.location.origin, playsinline: 1, }, + events: { + onStateChange: (e: any) => { + // 0 === ended → mark watched even if the timer hasn't fired yet. + if (e?.data === 0 && !autoMarkedRef.current) { + autoMarkedRef.current = true; + setWatched(true); + } + }, + }, }); }); - // Periodic checkpoint so progress survives a crash/refresh, not just a clean close. + // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh. const timer = window.setInterval(persist, 5000); return () => { @@ -116,6 +163,7 @@ export default function PlayerModal({ } playerRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [video.id]); return ( @@ -129,71 +177,91 @@ export default function PlayerModal({ className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl" onClick={(e) => e.stopPropagation()} > - {/* Header bar — keeps our close button off the player's own top-right controls. */} -
- {video.title} - -
- -
+
-

{video.title}

+ {/* Title row — title (with hover description) on the left, Close on the right. */} +
+
setShowDesc(true)} + onMouseLeave={() => setShowDesc(false)} + > +

+ {video.title} +

+ {showDesc && ( +
+
+ Description +
+ {detail.isLoading ? ( +
Loading…
+ ) : detail.data?.description ? ( +
+ {detail.data.description} +
+ ) : ( +
No description.
+ )} +
+ )} +
+ +
+ {/* Channel + meta on one line, with the watched toggle pushed to the right. */}
{video.channel_thumbnail && ( )} {video.channel_title} -
+
+ {video.view_count != null && · {formatViews(video.view_count)} views} + · {relativeTime(video.published_at)} + {video.duration_seconds != null && ( + · {formatDuration(video.duration_seconds)} + )} + {video.live_status === "was_live" && ( + + stream + + )} +
-
- {video.view_count != null && {formatViews(video.view_count)} views} - {video.view_count != null && ·} - {relativeTime(video.published_at)} - {video.duration_seconds != null && ( - <> - · - {formatDuration(video.duration_seconds)} - - )} - {video.live_status === "was_live" && ( - - stream - - )} -
- -
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 3d30060..c04aae6 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -1,4 +1,4 @@ -import { Bookmark, Check, Eye, EyeOff, ListFilter } from "lucide-react"; +import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react"; import clsx from "clsx"; import type { Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; @@ -21,20 +21,24 @@ function Actions({