Ranked feed — affinity was broken:
- Was looking up user_tag_affinity by v.category (e.g. "Science & Technology")
but affinity is stored using fine-grained video tags ("linux", "rust", etc.)
- Now uses SUM across all matching affinities: category OR any tag found in the
video's tags JSON via instr() — up to 5 matches to prevent runaway scores
Ranked feed — completion rate now influences channel scoring:
- Added avg_completion_pct to channel_stats CTE (AVG of completion_percent)
- Channels where you finish videos score higher; channels you bail on score lower
- Defaults to 50% (neutral) for channels with no tracked completions
Progress endpoint — backend auto-watched safety net:
- If completion_percent reaches ≥90% on a video >60s, mark watched automatically
- Catches cases where browser closes before the 10s debounce fires
- Guards against double-calling _update_affinity with not prev_watched check
VideoPlayer — seamless local file switch:
- Removed switchedToLocal state which caused a race condition: video loaded with
local_file_url already set but flag was still false, requiring a page refresh
- local_file_url from the backend is the single source of truth (backend gates
it with os.path.exists so it only appears when the file is actually on disk)
- Show spinner while video metadata loads, then immediately show local player
if file exists — no YouTube flash for already-downloaded videos
- After download completes, single refetchVideo() picks up the new URL and
React re-renders directly into local player
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
import { useEffect, useRef, useState, useCallback } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { getVideoByYtId, updateProgress, createDownload, followChannelByUrl, getDownload } from "../api";
|
|
|
|
function formatDuration(s) {
|
|
if (!s) return "";
|
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
return `${m}:${String(sec).padStart(2, "0")}`;
|
|
}
|
|
|
|
function CloseIcon() {
|
|
return (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function YoutubeEmbed({ youtubeId, startAt, onTimeUpdate }) {
|
|
useEffect(() => {
|
|
const handler = (e) => {
|
|
if (e.origin !== "https://www.youtube.com") return;
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data.event === "infoDelivery" && data.info?.currentTime != null) {
|
|
onTimeUpdate(Math.floor(data.info.currentTime));
|
|
}
|
|
} catch {}
|
|
};
|
|
window.addEventListener("message", handler);
|
|
return () => window.removeEventListener("message", handler);
|
|
}, [onTimeUpdate]);
|
|
|
|
const start = startAt > 10 ? startAt : 0;
|
|
const src = `https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0&modestbranding=1&enablejsapi=1&start=${start}&origin=${encodeURIComponent(window.location.origin)}`;
|
|
|
|
return (
|
|
<iframe
|
|
src={src}
|
|
className="w-full aspect-video rounded-lg bg-black"
|
|
allow="autoplay; fullscreen; picture-in-picture"
|
|
allowFullScreen
|
|
/>
|
|
);
|
|
}
|
|
|
|
function LocalVideo({ src, startAt, onTimeUpdate }) {
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (ref.current && startAt > 10) {
|
|
ref.current.currentTime = startAt;
|
|
}
|
|
}, []); // only on mount
|
|
|
|
return (
|
|
<video
|
|
ref={ref}
|
|
src={src}
|
|
controls
|
|
autoPlay
|
|
className="w-full aspect-video rounded-lg bg-black"
|
|
onTimeUpdate={() => {
|
|
if (ref.current) onTimeUpdate(Math.floor(ref.current.currentTime));
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function DownloadProgress({ pct, status }) {
|
|
const label = status === "pending" ? "Queued…" : `Downloading ${pct.toFixed(0)}%`;
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full transition-all duration-500"
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-zinc-500 shrink-0 font-mono w-28">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function VideoPlayer() {
|
|
const [params, setParams] = useSearchParams();
|
|
const qc = useQueryClient();
|
|
const youtubeId = params.get("play");
|
|
const urlTitle = params.get("pt");
|
|
const urlChannel = params.get("pc");
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [downloadId, setDownloadId] = useState(null);
|
|
const saveTimerRef = useRef(null);
|
|
const initiatedRef = useRef(null);
|
|
|
|
// ── Video metadata ────────────────────────────────────────────────────────
|
|
const { data: video, refetch: refetchVideo } = useQuery({
|
|
queryKey: ["video-play", youtubeId],
|
|
queryFn: () =>
|
|
getVideoByYtId(youtubeId)
|
|
.then((r) => r.data)
|
|
.catch((err) => (err.response?.status === 404 ? null : Promise.reject(err))),
|
|
enabled: !!youtubeId,
|
|
staleTime: 0,
|
|
});
|
|
|
|
// ── Download polling ──────────────────────────────────────────────────────
|
|
const { data: dlStatus } = useQuery({
|
|
queryKey: ["download-status", downloadId],
|
|
queryFn: () => getDownload(downloadId).then((r) => r.data),
|
|
enabled: !!downloadId,
|
|
refetchInterval: (query) => {
|
|
const s = query.state.data?.status;
|
|
return s === "complete" || s === "failed" ? false : 1500;
|
|
},
|
|
});
|
|
|
|
// When download finishes, refetch video — local_file_url will appear once the
|
|
// file exists on disk, which is the single source of truth for switching players
|
|
useEffect(() => {
|
|
if (dlStatus?.status === "complete" && !video?.local_file_url) {
|
|
refetchVideo();
|
|
}
|
|
}, [dlStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// ── Trigger download on open ──────────────────────────────────────────────
|
|
const downloadMut = useMutation({
|
|
mutationFn: (ytId) => createDownload(ytId),
|
|
onSuccess: (res) => {
|
|
const dl = res.data;
|
|
setDownloadId(dl.id);
|
|
// If already complete (pre-downloaded), refetch to get local_file_url
|
|
if (dl.status === "complete") refetchVideo();
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
|
initiatedRef.current = youtubeId;
|
|
setCurrentTime(0);
|
|
setDownloadId(null);
|
|
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
|
return () => clearTimeout(t);
|
|
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// ── Progress saving ───────────────────────────────────────────────────────
|
|
const followMut = useMutation({
|
|
mutationFn: () => followChannelByUrl({ youtube_channel_id: video?.channel_youtube_id }),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
|
});
|
|
|
|
const handleTimeUpdate = useCallback((secs) => {
|
|
setCurrentTime(secs);
|
|
clearTimeout(saveTimerRef.current);
|
|
saveTimerRef.current = setTimeout(() => {
|
|
if (video?.id) {
|
|
const duration = video.duration_seconds ?? 0;
|
|
const watched = duration > 0 && secs >= duration * 0.9;
|
|
updateProgress(video.id, { watch_progress_seconds: secs, watched });
|
|
}
|
|
}, 10_000);
|
|
}, [video]);
|
|
|
|
const close = useCallback(() => {
|
|
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
|
clearTimeout(saveTimerRef.current);
|
|
}, [setParams]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e) => { if (e.key === "Escape") close(); };
|
|
document.addEventListener("keydown", handler);
|
|
return () => document.removeEventListener("keydown", handler);
|
|
}, [close]);
|
|
|
|
useEffect(() => () => clearTimeout(saveTimerRef.current), []);
|
|
|
|
if (!youtubeId) return null;
|
|
|
|
const title = video?.title ?? urlTitle ?? youtubeId;
|
|
const channelName = video?.channel_name ?? urlChannel;
|
|
const startAt = video?.watch_progress_seconds ?? 0;
|
|
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
|
// local_file_url is only set by the backend when the file actually exists on disk
|
|
const localUrl = video?.local_file_url ?? null;
|
|
const videoLoading = !video;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm p-4"
|
|
onClick={(e) => { if (e.target === e.currentTarget) close(); }}
|
|
>
|
|
<div className="relative w-full max-w-4xl flex flex-col gap-3 max-h-[95vh] overflow-y-auto">
|
|
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<h2 className="font-display font-semibold text-white text-lg leading-snug line-clamp-2">
|
|
{title}
|
|
</h2>
|
|
{channelName && (
|
|
<p className="text-sm text-zinc-400 mt-0.5">{channelName}</p>
|
|
)}
|
|
</div>
|
|
<button onClick={close} className="shrink-0 text-zinc-400 hover:text-white transition-colors p-1">
|
|
<CloseIcon />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Player — wait for metadata, then show local file or YouTube embed */}
|
|
{videoLoading ? (
|
|
<div className="w-full aspect-video rounded-lg bg-zinc-900 flex items-center justify-center">
|
|
<svg className="w-8 h-8 animate-spin text-zinc-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
|
</svg>
|
|
</div>
|
|
) : localUrl ? (
|
|
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
|
) : (
|
|
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
|
)}
|
|
|
|
{/* Download progress bar (shows while downloading, disappears when done) */}
|
|
{isDownloading && (
|
|
<DownloadProgress
|
|
pct={dlStatus.progress_percent ?? 0}
|
|
status={dlStatus.status}
|
|
/>
|
|
)}
|
|
|
|
{/* Status / source indicator */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{localUrl ? (
|
|
<span className="text-xs text-accent font-medium">▶ Playing local file</span>
|
|
) : isDownloading ? (
|
|
<span className="text-xs text-zinc-500">Watching on YouTube · switching to local when ready</span>
|
|
) : dlStatus?.status === "failed" ? (
|
|
<span className="text-xs text-red-400">Download failed — watching on YouTube</span>
|
|
) : null}
|
|
|
|
{/* Follow channel */}
|
|
{video?.channel_youtube_id && (
|
|
<button
|
|
onClick={() => followMut.mutate()}
|
|
disabled={followMut.isPending || followMut.isSuccess}
|
|
className="ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
|
>
|
|
{followMut.isSuccess ? "Following ✓" : "Follow channel"}
|
|
</button>
|
|
)}
|
|
|
|
{/* Duration */}
|
|
{video?.duration_seconds && (
|
|
<span className="text-xs text-zinc-600 font-mono">
|
|
{formatDuration(currentTime || startAt)} / {formatDuration(video.duration_seconds)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{video?.description && (
|
|
<details>
|
|
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300 transition-colors select-none">
|
|
Description
|
|
</summary>
|
|
<p className="text-sm text-zinc-400 mt-2 whitespace-pre-line leading-relaxed max-h-40 overflow-y-auto">
|
|
{video.description}
|
|
</p>
|
|
</details>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|