Add lazy comment fetching to watch page
- VideoComment model (video_id, author, text, likes, is_pinned, published_at)
- fetch_video_comments() in ytdlp.py: top 20 comments, no reply threads,
sorted pinned-first then by likes
- GET /videos/by-yt/{id}/comments — returns cached comments instantly
- POST /videos/by-yt/{id}/comments/refresh — fetches from YouTube, stores, returns
- Watch page: CommentsSection shows "Load comments" button when uncached,
renders comments with author/likes once loaded; Refresh link to re-fetch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,8 @@ export const longVideos = () => api.get("/videos/long");
|
||||
export const surpriseMe = () => api.get("/videos/surprise");
|
||||
export const getVideo = (id) => api.get(`/videos/${id}`);
|
||||
export const getVideoByYtId = (ytId) => api.get(`/videos/by-yt/${ytId}`);
|
||||
export const getVideoComments = (ytId) => api.get(`/videos/by-yt/${ytId}/comments`);
|
||||
export const refreshVideoComments = (ytId) => api.post(`/videos/by-yt/${ytId}/comments/refresh`);
|
||||
export const updateProgress = (id, data) => api.patch(`/videos/${id}/progress`, data);
|
||||
export const toggleQueue = (id) => api.post(`/videos/${id}/queue`);
|
||||
export const getQueue = () => api.get("/videos/queue");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSettings, updateSettings, getRelatedVideos, getDownloads, rateVideo,
|
||||
getBookmarks, createBookmark, updateBookmark, deleteBookmark, importChapters, clearChapters,
|
||||
getCollections, addToCollection, getQueue,
|
||||
getVideoComments, refreshVideoComments,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
@@ -465,6 +466,79 @@ function BookmarksSection({ videoId, videoRef }) {
|
||||
}
|
||||
|
||||
|
||||
function CommentsSection({ youtubeVideoId }) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: comments = [], isLoading } = useQuery({
|
||||
queryKey: ["comments", youtubeVideoId],
|
||||
queryFn: () => getVideoComments(youtubeVideoId).then(r => r.data),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const refresh = useMutation({
|
||||
mutationFn: () => refreshVideoComments(youtubeVideoId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", youtubeVideoId] }),
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!comments.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<p className="text-sm text-zinc-500">No comments loaded yet</p>
|
||||
<button
|
||||
onClick={() => refresh.mutate()}
|
||||
disabled={refresh.isPending}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{refresh.isPending ? (
|
||||
<>
|
||||
<span className="w-3 h-3 border border-zinc-500 border-t-transparent rounded-full animate-spin inline-block" />
|
||||
Fetching…
|
||||
</>
|
||||
) : "Load comments"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">
|
||||
Top comments
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refresh.mutate()}
|
||||
disabled={refresh.isPending}
|
||||
className="text-xs text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
{refresh.isPending ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{comments.map((c, i) => (
|
||||
<div key={i} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-zinc-300">{c.author}</span>
|
||||
{c.is_pinned && (
|
||||
<span className="text-[10px] text-accent font-medium">Pinned</span>
|
||||
)}
|
||||
{c.likes > 0 && (
|
||||
<span className="text-[10px] text-zinc-600 ml-auto">
|
||||
👍 {c.likes >= 1000 ? `${(c.likes / 1000).toFixed(c.likes >= 10000 ? 0 : 1)}K` : c.likes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 leading-relaxed">{c.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function Watch() {
|
||||
@@ -1016,6 +1090,11 @@ export default function Watch() {
|
||||
<BookmarksSection videoId={video.id} videoRef={videoRef} />
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{video?.youtube_video_id && (
|
||||
<CommentsSection youtubeVideoId={video.youtube_video_id} />
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcuts hint */}
|
||||
<p className={`text-xs text-zinc-700 text-center ${theater ? "max-w-4xl mx-auto w-full" : ""}`}>
|
||||
Space/K · pause · F · fullscreen · M · mute · ←/→ seek 5s · ↑/↓ volume · ,/. speed · T · theater
|
||||
|
||||
Reference in New Issue
Block a user