feat(player): watched controls, compact layout, description popover

- 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).
This commit is contained in:
npeter83 2026-06-12 17:39:01 +02:00
parent 75b73b5933
commit 1616bcc223
5 changed files with 161 additions and 60 deletions

View file

@ -287,3 +287,21 @@ def set_video_state(
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
db.commit() db.commit()
return {"video_id": video_id, "status": status} 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,
}

View file

@ -139,7 +139,11 @@ export default function Feed({
</div> </div>
)} )}
{activeVideo && ( {activeVideo && (
<PlayerModal video={activeVideo} onClose={() => setActiveVideo(null)} /> <PlayerModal
video={activeVideo}
onClose={() => setActiveVideo(null)}
onState={onState}
/>
)} )}
<div ref={sentinel} className="h-10" /> <div ref={sentinel} className="h-10" />
{isFetchingNextPage && ( {isFetchingNextPage && (

View file

@ -1,13 +1,17 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { ExternalLink, X } from "lucide-react"; import { useQuery } from "@tanstack/react-query";
import type { Video } from "../lib/api"; import { Check, CheckCheck, X } from "lucide-react";
import { api, type Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
// Experiment (branch experiment/inline-player): play the video in-app via the // 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 // 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, // and resume where the user left off. The modal closes via the in-card Close
// the backdrop, or ESC (ESC only while focus is on our page, not inside the // button, the backdrop, or ESC (ESC only while focus is on our page, not inside
// cross-origin player iframe — a browser security boundary we can't cross). // 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) --- // --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null; let apiPromise: Promise<any> | null = null;
@ -32,7 +36,7 @@ function loadYouTubeApi(): Promise<any> {
const posKey = (id: string) => `subfeed:player-pos:${id}`; const posKey = (id: string) => `subfeed:player-pos:${id}`;
function savePos(id: string, seconds: number, duration: number): void { function savePos(id: string, seconds: number, duration: number): void {
// Don't store trivially-early or near-finished positions (start fresh next time). // 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)); localStorage.removeItem(posKey(id));
return; return;
} }
@ -47,12 +51,34 @@ function loadPos(id: string): number {
export default function PlayerModal({ export default function PlayerModal({
video, video,
onClose, onClose,
onState,
}: { }: {
video: Video; video: Video;
onClose: () => void; onClose: () => void;
onState: (id: string, status: string) => void;
}) { }) {
const mountRef = useRef<HTMLDivElement | null>(null); const mountRef = useRef<HTMLDivElement | null>(null);
const playerRef = useRef<any>(null); const playerRef = useRef<any>(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. // ESC + background scroll lock.
useEffect(() => { useEffect(() => {
@ -68,15 +94,27 @@ export default function PlayerModal({
}; };
}, [onClose]); }, [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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const id = video.id; 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 persist = () => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.getCurrentTime !== "function") return; if (!p || typeof p.getCurrentTime !== "function") return;
try { 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 { } catch {
/* player may be tearing down */ /* player may be tearing down */
} }
@ -96,10 +134,19 @@ export default function PlayerModal({
origin: window.location.origin, origin: window.location.origin,
playsinline: 1, 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); const timer = window.setInterval(persist, 5000);
return () => { return () => {
@ -116,6 +163,7 @@ export default function PlayerModal({
} }
playerRef.current = null; playerRef.current = null;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video.id]); }, [video.id]);
return ( 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" className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header bar — keeps our close button off the player's own top-right controls. */} <div className="aspect-video w-full bg-black rounded-t-2xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border">
<span className="truncate text-sm font-medium text-muted">{video.title}</span>
<button
onClick={onClose}
title="Close (Esc)"
aria-label="Close"
className="shrink-0 p-2 -mr-2 rounded-full text-muted hover:text-fg hover:bg-surface transition"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="aspect-video w-full bg-black">
<div ref={mountRef} className="w-full h-full" /> <div ref={mountRef} className="w-full h-full" />
</div> </div>
<div className="p-4 sm:p-5"> <div className="p-4 sm:p-5">
<h2 className="text-lg font-semibold leading-snug">{video.title}</h2> {/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<div
className="relative min-w-0 flex-1"
onMouseEnter={() => setShowDesc(true)}
onMouseLeave={() => setShowDesc(false)}
>
<h2 className="text-lg font-semibold leading-snug cursor-default">
{video.title}
</h2>
{showDesc && (
<div className="absolute left-0 top-full mt-2 z-10 w-full max-w-2xl glass-card rounded-xl shadow-2xl p-4">
<div className="text-xs uppercase tracking-wide text-muted mb-2">
Description
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">Loading</div>
) : detail.data?.description ? (
<div className="text-sm whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed">
{detail.data.description}
</div>
) : (
<div className="text-sm text-muted">No description.</div>
)}
</div>
)}
</div>
<button
onClick={onClose}
title="Close (Esc)"
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
Close
<X className="w-4 h-4" />
</button>
</div>
{/* Channel + meta on one line, with the watched toggle pushed to the right. */}
<div className="flex items-center gap-3 mt-3"> <div className="flex items-center gap-3 mt-3">
{video.channel_thumbnail && ( {video.channel_thumbnail && (
<img <img
src={video.channel_thumbnail} src={video.channel_thumbnail}
alt="" alt=""
className="w-10 h-10 rounded-full shrink-0" className="w-9 h-9 rounded-full shrink-0"
/> />
)} )}
<a <a
href={video.channel_url} href={video.channel_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="font-medium hover:text-accent truncate" className="font-medium hover:text-accent shrink-0"
> >
{video.channel_title} {video.channel_title}
</a> </a>
</div> <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{video.view_count != null && <span>· {formatViews(video.view_count)} views</span>}
<span>· {relativeTime(video.published_at)}</span>
{video.duration_seconds != null && (
<span>· {formatDuration(video.duration_seconds)}</span>
)}
{video.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted mt-3"> <button
{video.view_count != null && <span>{formatViews(video.view_count)} views</span>} onClick={() => setWatched(!watched)}
{video.view_count != null && <span>·</span>} title={watched ? "Watched — click to unmark" : "Mark watched"}
<span>{relativeTime(video.published_at)}</span> className={
{video.duration_seconds != null && ( "ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
<> (watched
<span>·</span> ? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
<span>{formatDuration(video.duration_seconds)}</span> : "text-muted hover:text-fg hover:bg-surface border border-border")
</> }
)}
{video.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
</span>
)}
</div>
<div className="mt-4">
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
> >
<ExternalLink className="w-4 h-4" /> {watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
Open on YouTube {watched ? "Watched" : "Mark watched"}
</a> </button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -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 clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDuration, formatViews, relativeTime } from "../lib/format";
@ -21,20 +21,24 @@ function Actions({
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button <button
onClick={act("watched")} onClick={act("watched")}
title="Mark watched" title={video.status === "watched" ? "Watched — click to unmark" : "Mark watched"}
className={clsx( className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg", "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent" video.status === "watched" && "text-accent"
)} )}
> >
<Check className="w-4 h-4" /> {video.status === "watched" ? (
<CheckCheck className="w-4 h-4" />
) : (
<Check className="w-4 h-4" />
)}
</button> </button>
<button <button
onClick={act("saved")} onClick={act("saved")}
title="Save for later" title={video.status === "saved" ? "Saved — click to remove" : "Save for later"}
className={clsx( className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg", "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "text-accent" video.status === "saved" && "fill-current text-accent"
)} )}
> >
<Bookmark className="w-4 h-4" /> <Bookmark className="w-4 h-4" />

View file

@ -47,6 +47,12 @@ export interface Video {
watch_url: string; watch_url: string;
} }
export interface VideoDetail {
id: string;
description: string | null;
like_count: number | null;
}
export interface FeedResponse { export interface FeedResponse {
items: Video[]; items: Video[];
has_more: boolean; has_more: boolean;
@ -214,6 +220,7 @@ export const api = {
req(`/api/feed/count?${feedQuery(f, 0, 0)}`), req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
setState: (id: string, status: string) => setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
pauseSync: () => req("/api/sync/pause", { method: "POST" }), pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }),
savePrefs: (p: Record<string, any>) => savePrefs: (p: Record<string, any>) =>