From 47d07994070fb8f64d7f83a4861c70a312112c05 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 12 Jun 2026 17:39:20 +0200 Subject: [PATCH] feat(player): linkify descriptions and play YouTube links inline - Narrow the title hover target to the actual text, not the whole row. - Linkify descriptions: timestamps (mm:ss / hh:mm:ss) seek the player; emails become mailto:; hashtags link to YouTube's hashtag feed; other URLs open in a new tab. Blank lines are stripped so the popover isn't mostly whitespace. - YouTube links play in the inline player: a link to the current video seeks (honoring t=), a link to another video navigates the player to it, with a Back button to the original. While on a linked video the title/author come from the player and its views/date/duration + a clickable channel come from the detail endpoint, which falls back to the YouTube API (videos.list, attributed to the user) for videos not in our DB. --- backend/app/routes/feed.py | 49 +++- frontend/src/components/PlayerModal.tsx | 343 ++++++++++++++++++++---- frontend/src/lib/api.ts | 6 + 3 files changed, 335 insertions(+), 63 deletions(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 87bf28c..2917244 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -4,9 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import Select, and_, false, func, or_, select 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.sync.videos import parse_iso8601_duration +from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) @@ -296,12 +299,48 @@ def get_video_detail( 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.""" + so the feed stays lean; fetched lazily, e.g. for the title hover popover. + + Videos we already store are served from the DB for free. A video that isn't in + our DB (e.g. a YouTube link inside another video's description that the in-app + player navigated to) is resolved via the YouTube API (videos.list, 1 unit, + attributed to the requesting user).""" v = db.get(Video, video_id) - if v is None: + if v is not None: + return { + "id": v.id, + "description": v.description, + "like_count": v.like_count, + "in_db": True, + "channel_id": v.channel_id, + "channel_title": v.channel.title if v.channel else None, + "published_at": v.published_at.isoformat() if v.published_at else None, + "view_count": v.view_count, + "duration_seconds": v.duration_seconds, + } + + try: + with quota.attribute(user.id, "video_lookup"), YouTubeClient(db, user) as yt: + items = yt.get_videos([video_id]) + except YouTubeError as exc: + raise HTTPException(status_code=502, detail=f"YouTube lookup failed: {exc}") + if not items: raise HTTPException(status_code=404, detail="Unknown video") + + snippet = items[0].get("snippet", {}) + stats = items[0].get("statistics", {}) + likes = stats.get("likeCount") + views = stats.get("viewCount") return { - "id": v.id, - "description": v.description, - "like_count": v.like_count, + "id": video_id, + "description": snippet.get("description"), + "like_count": int(likes) if likes is not None else None, + "in_db": False, + "channel_id": snippet.get("channelId"), + "channel_title": snippet.get("channelTitle"), + "published_at": snippet.get("publishedAt"), + "view_count": int(views) if views is not None else None, + "duration_seconds": parse_iso8601_duration( + items[0].get("contentDetails", {}).get("duration") + ), } diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 56a0fc4..05c4083 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,10 +1,152 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; -import { Check, CheckCheck, X } from "lucide-react"; +import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; import { api, type Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; +// Turn a description into clickable nodes: +// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player +// - a YouTube link to the *current* video → seek (to its t= if any) +// - a YouTube link to *another* video → load it in the inline player +// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab +const URL_RE = /(https?:\/\/[^\s<>]+)/g; +// One pass over plain text for the three inline token kinds (email | hashtag | timestamp). +const INLINE_RE = + /([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu; + +function tsToSeconds(ts: string): number { + const parts = ts.split(":").map((n) => parseInt(n, 10)); + return parts.length === 3 + ? parts[0] * 3600 + parts[1] * 60 + parts[2] + : parts[0] * 60 + parts[1]; +} + +// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s". +function parseStart(t: string | null): number | null { + if (!t) return null; + if (/^\d+$/.test(t)) return parseInt(t, 10) || null; + const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); + if (!m) return null; + const total = + parseInt(m[1] || "0", 10) * 3600 + + parseInt(m[2] || "0", 10) * 60 + + parseInt(m[3] || "0", 10); + return total || null; +} + +// Extract a video id (+ optional start) from a YouTube URL, else null. +function parseYouTube(url: string): { id: string; start: number | null } | null { + let u: URL; + try { + u = new URL(url); + } catch { + return null; + } + const host = u.hostname.replace(/^www\./, ""); + let id: string | null = null; + if (host === "youtu.be") { + id = u.pathname.slice(1) || null; + } else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") { + if (u.pathname === "/watch") id = u.searchParams.get("v"); + else { + const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/); + if (m) id = m[1]; + } + } + if (!id) return null; + return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) }; +} + +function renderDescription( + text: string, + opts: { + currentId: string; + onSeek: (seconds: number) => void; + onLoadVideo: (id: string, start: number | null) => void; + } +): ReactNode[] { + const out: ReactNode[] = []; + let key = 0; + const linkCls = "text-accent hover:underline break-all"; + // Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely + // (they're just noise in the popover), keeping single line breaks. + const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim(); + for (const chunk of clean.split(URL_RE)) { + if (/^https?:\/\//.test(chunk)) { + const href = chunk.replace(/[.,;:!?)\]]+$/, ""); + const yt = parseYouTube(href); + if (yt) { + const sameVideo = yt.id === opts.currentId; + out.push( + + ); + } else { + out.push( + + {href} + + ); + } + continue; + } + // Plain text: pull out emails, hashtags and bare timestamps. + INLINE_RE.lastIndex = 0; + let last = 0; + let m: RegExpExecArray | null; + while ((m = INLINE_RE.exec(chunk))) { + if (m.index > last) out.push({chunk.slice(last, m.index)}); + if (m[1]) { + // Email — trim trailing punctuation the domain rule may have swallowed. + const email = m[1].replace(/[.,;:]+$/, ""); + out.push( + + {email} + + ); + if (m[1].length > email.length) out.push({m[1].slice(email.length)}); + } else if (m[2]) { + // Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior). + const tag = m[2].slice(1); + out.push( + + {m[2]} + + ); + } else { + const ts = m[3]; + out.push( + + ); + } + last = m.index + m[0].length; + } + if (last < chunk.length) out.push({chunk.slice(last)}); + } + return out; +} + // 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 in-card Close @@ -62,6 +204,24 @@ export default function PlayerModal({ 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; + // 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; + const startSeconds = start != null ? start : loadPos(id); + p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); + currentIdRef.current = id; + setCurrentVideoId(id); + setLiveData(null); + }; + // 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); @@ -72,6 +232,14 @@ export default function PlayerModal({ onState(video.id, next); }; + const seekTo = (seconds: number) => { + const p = playerRef.current; + if (p && typeof p.seekTo === "function") { + p.seekTo(seconds, true); + p.playVideo?.(); + } + }; + // Lazy description (fetched only when the title is hovered). The popover is // portaled to with fixed positioning so the modal card's overflow-y-auto // can't clip it. A small close grace lets the mouse travel title → popover. @@ -81,14 +249,16 @@ export default function PlayerModal({ const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>( null ); - const titleRef = useRef(null); + const titleRef = useRef(null); const closeTimer = useRef(undefined); const openDesc = () => { window.clearTimeout(closeTimer.current); const el = titleRef.current; if (el) { const r = el.getBoundingClientRect(); - setDescRect({ left: r.left, bottom: window.innerHeight - r.top + 8, width: r.width }); + const width = Math.min(560, window.innerWidth - 32); + const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16)); + setDescRect({ left, bottom: window.innerHeight - r.top + 8, width }); } setShowDesc(true); }; @@ -96,9 +266,11 @@ export default function PlayerModal({ closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); }; const detail = useQuery({ - queryKey: ["video-detail", video.id], - queryFn: () => api.videoDetail(video.id), - enabled: showDesc, + queryKey: ["video-detail", currentVideoId], + queryFn: () => api.videoDetail(currentVideoId), + // On hover (for the description) and eagerly when navigated, so we can link the + // linked video's channel. Both share the one cached result. + enabled: showDesc || navigated, staleTime: 5 * 60_000, }); @@ -123,8 +295,10 @@ export default function PlayerModal({ let cancelled = false; const id = video.id; + // Auto-watch only applies to the feed video we opened with — not to other + // videos the player navigated to via description links. const maybeAutoWatch = (current: number, duration: number) => { - if (autoMarkedRef.current || duration <= 0) return; + if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (current > duration - FINISH_MARGIN) { autoMarkedRef.current = true; setWatched(true); @@ -137,7 +311,7 @@ export default function PlayerModal({ const cur = p.getCurrentTime(); const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; maybeAutoWatch(cur, dur); - savePos(id, cur, dur); + savePos(currentIdRef.current, cur, dur); // key progress by what's actually playing } catch { /* player may be tearing down */ } @@ -159,8 +333,14 @@ export default function PlayerModal({ }, events: { onStateChange: (e: any) => { - // 0 === ended → mark watched even if the timer hasn't fired yet. - if (e?.data === 0 && !autoMarkedRef.current) { + // 1 === playing → sync the navigated-to video's title/author for display. + if (e?.data === 1) { + const p = playerRef.current; + 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); } @@ -207,16 +387,27 @@ export default function PlayerModal({
{/* Title row — title (with hover description) on the left, Close on the right. */}
-
-

- {video.title} -

-
+

+ {/* Hover target is the text itself (inline), not the whole row. */} + + {navigated ? liveData?.title ?? "Loading…" : video.title} + +

+ {navigated && ( + + )} {showDesc && descRect && createPortal( @@ -236,8 +427,12 @@ export default function PlayerModal({ {detail.isLoading ? (
Loading…
) : detail.data?.description ? ( -
- {detail.data.description} +
+ {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + })}
) : (
No description.
@@ -255,49 +450,81 @@ export default function PlayerModal({
- {/* Channel + meta on one line, with the watched toggle pushed to the right. */} + {/* Channel + meta on one line, with the watched toggle pushed to the right. + When navigated to a linked video we only have its author (from the player), + so we show that as plain text and hide feed-video-specific bits. */}
- {video.channel_thumbnail && ( + {!navigated && 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 - - )} -
+ {navigated ? ( + detail.data?.channel_id ? ( + + {liveData?.author ?? detail.data.channel_title ?? "Channel"} + + ) : ( + {liveData?.author ?? ""} + ) + ) : ( + + {video.channel_title} + + )} + {!navigated ? ( +
+ {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 + + )} +
+ ) : ( + // Linked video's stats come from the (already-fetched) video detail. +
+ {detail.data?.view_count != null && ( + · {formatViews(detail.data.view_count)} views + )} + {detail.data?.published_at && · {relativeTime(detail.data.published_at)}} + {detail.data?.duration_seconds != null && ( + · {formatDuration(detail.data.duration_seconds)} + )} +
+ )} - + {!navigated && ( + + )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 733d55d..fee444c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,6 +51,12 @@ export interface VideoDetail { id: string; description: string | null; like_count: number | null; + in_db: boolean; + channel_id: string | null; + channel_title: string | null; + published_at: string | null; + view_count: number | null; + duration_seconds: number | null; } export interface FeedResponse {