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;
+}