diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index fcbc7bb..88a1483 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; @@ -8,152 +7,9 @@ import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; +import { renderDescription } from "../lib/descriptionLinks"; import { useBackToClose } from "../lib/history"; -// 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; - t: TFunction; - } -): ReactNode[] { - const { t } = opts; - 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 diff --git a/frontend/src/lib/descriptionLinks.tsx b/frontend/src/lib/descriptionLinks.tsx new file mode 100644 index 0000000..dfc25b4 --- /dev/null +++ b/frontend/src/lib/descriptionLinks.tsx @@ -0,0 +1,147 @@ +import type { ReactNode } from "react"; +import type { TFunction } from "i18next"; + +// Turn a video 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 +// Pure logic (no React state), factored out of PlayerModal so it's testable on its own. +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")) }; +} + +export function renderDescription( + text: string, + opts: { + currentId: string; + onSeek: (seconds: number) => void; + onLoadVideo: (id: string, start: number | null) => void; + t: TFunction; + } +): ReactNode[] { + const { t } = opts; + 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; +}