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.
This commit is contained in:
parent
37a7723c8d
commit
ea2e1fb5a7
3 changed files with 335 additions and 63 deletions
|
|
@ -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(
|
||||
<button
|
||||
key={key++}
|
||||
onClick={() =>
|
||||
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
||||
}
|
||||
className={linkCls}
|
||||
title={sameVideo ? "Jump to this time" : "Play in the in-app player"}
|
||||
>
|
||||
{href}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
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(<span key={key++}>{chunk.slice(last, m.index)}</span>);
|
||||
if (m[1]) {
|
||||
// Email — trim trailing punctuation the domain rule may have swallowed.
|
||||
const email = m[1].replace(/[.,;:]+$/, "");
|
||||
out.push(
|
||||
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
||||
} else if (m[2]) {
|
||||
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
||||
const tag = m[2].slice(1);
|
||||
out.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkCls}
|
||||
>
|
||||
{m[2]}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
const ts = m[3];
|
||||
out.push(
|
||||
<button
|
||||
key={key++}
|
||||
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{ts}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
|
||||
}
|
||||
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<any>(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 <body> 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<HTMLDivElement | null>(null);
|
||||
const titleRef = useRef<HTMLSpanElement | null>(null);
|
||||
const closeTimer = useRef<number | undefined>(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({
|
|||
<div className="p-4 sm:p-5">
|
||||
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
ref={titleRef}
|
||||
className="min-w-0 flex-1"
|
||||
onMouseEnter={openDesc}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
<h2 className="text-lg font-semibold leading-snug cursor-default">
|
||||
{video.title}
|
||||
</h2>
|
||||
</div>
|
||||
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{/* Hover target is the text itself (inline), not the whole row. */}
|
||||
<span
|
||||
ref={titleRef}
|
||||
className="cursor-default"
|
||||
onMouseEnter={openDesc}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
{navigated ? liveData?.title ?? "Loading…" : video.title}
|
||||
</span>
|
||||
</h2>
|
||||
{navigated && (
|
||||
<button
|
||||
onClick={() => loadVideo(video.id, null)}
|
||||
title="Back to the original video"
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{showDesc &&
|
||||
descRect &&
|
||||
createPortal(
|
||||
|
|
@ -236,8 +427,12 @@ export default function PlayerModal({
|
|||
{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 className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">No description.</div>
|
||||
|
|
@ -255,49 +450,81 @@ export default function PlayerModal({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
{video.channel_thumbnail && (
|
||||
{!navigated && video.channel_thumbnail && (
|
||||
<img
|
||||
src={video.channel_thumbnail}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-full shrink-0"
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={video.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium hover:text-accent shrink-0"
|
||||
>
|
||||
{video.channel_title}
|
||||
</a>
|
||||
<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>
|
||||
{navigated ? (
|
||||
detail.data?.channel_id ? (
|
||||
<a
|
||||
href={`https://www.youtube.com/channel/${detail.data.channel_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium hover:text-accent shrink-0"
|
||||
>
|
||||
{liveData?.author ?? detail.data.channel_title ?? "Channel"}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
||||
)
|
||||
) : (
|
||||
<a
|
||||
href={video.channel_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium hover:text-accent shrink-0"
|
||||
>
|
||||
{video.channel_title}
|
||||
</a>
|
||||
)}
|
||||
{!navigated ? (
|
||||
<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>
|
||||
) : (
|
||||
// Linked video's stats come from the (already-fetched) video detail.
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
||||
{detail.data?.view_count != null && (
|
||||
<span>· {formatViews(detail.data.view_count)} views</span>
|
||||
)}
|
||||
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
|
||||
{detail.data?.duration_seconds != null && (
|
||||
<span>· {formatDuration(detail.data.duration_seconds)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? "Watched — click to unmark" : "Mark watched"}
|
||||
className={
|
||||
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||
(watched
|
||||
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||
}
|
||||
>
|
||||
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
|
||||
{watched ? "Watched" : "Mark watched"}
|
||||
</button>
|
||||
{!navigated && (
|
||||
<button
|
||||
onClick={() => setWatched(!watched)}
|
||||
title={watched ? "Watched — click to unmark" : "Mark watched"}
|
||||
className={
|
||||
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||
(watched
|
||||
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||
}
|
||||
>
|
||||
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
|
||||
{watched ? "Watched" : "Mark watched"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue