feat: the player's title opens the description

The title is the link now, and looks like nothing: it keeps its colour
and carries no underline, since dressing it up would compete with the
video for attention. The pointer and the tooltip say the rest.

Views and age move onto that line, in the same small quiet grey, and
the "› Description" disclosure underneath is gone — one line instead of
two, and the description gets a proper modal with room to read it.

Addresses in a description are plain text as YouTube stores them; they
are found and made clickable, and open in the real browser. Trailing
punctuation is trimmed from the target: a full stop ends the sentence,
not the address.

Verified in the running app on a description with three consecutive
affiliate links, all three of which came out as links — the test for
one uses startsWith rather than a global regex, whose lastIndex carries
between calls and would have left every other address as plain text.
This commit is contained in:
vincent
2026-09-03 23:47:27 +02:00
parent ddad42593c
commit 14dab91334
+64 -20
View File
@@ -6,7 +6,7 @@ import {
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls";
import { Badge, BTN, Spinner } from "./ui";
import { Badge, BTN, Dialog, Spinner } from "./ui";
interface Props {
item: FeedItem;
@@ -108,6 +108,40 @@ const RESUME_EDGE_S = 5;
* so watching still happens here rather than in a browser. The iframe embed
* cannot be used: it rejects a `tauri://` origin with "Error 153".
*/
/** Web addresses in a YouTube description, which are plain text as it stores them. */
const URL_IN_TEXT = /(https?:\/\/[^\s<>"']+)/g;
/**
* A description with its addresses made clickable.
*
* They open in the real browser: a YouTube link is the one thing in here this
* app has no way to show, and the rest belong to whoever wrote them.
*/
function Linked({ text }: { text: string }) {
return (
<>
{text.split(URL_IN_TEXT).map((part, i) =>
// Not URL_IN_TEXT.test: a global regex carries lastIndex between calls,
// so every other address would come out as plain text.
part.startsWith("http") ? (
// Trailing punctuation is sentence, not address.
<button
key={i}
onClick={() => openExternal(part.replace(/[.,;:!?)\]]+$/, ""))}
title={part}
className="cursor-pointer break-all text-left text-sky-600 underline
underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
{part}
</button>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
/**
* Rewrites every cue's settings to one placement.
*
@@ -154,6 +188,7 @@ export default function Player({
const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
const [tracks, setTracks] = useState<Array<[string, string]>>([]);
const [fetchingSubs, setFetchingSubs] = useState(false);
const [showDescription, setShowDescription] = useState(false);
// The language to fetch, which is NOT the preference: turning subtitles on
// from the player's menu moves the preference from "off" to that language,
// and refetching then would tear down the tracks — and the stream with them —
@@ -521,13 +556,29 @@ export default function Player({
dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2>
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
{/* Title and figures on one line. The title opens the description
rather than announcing itself as a link: it keeps its colour and
stays unadorned, and the pointer says the rest. */}
<div className="flex min-w-0 items-baseline gap-2">
{item.description ? (
<button
onClick={() => setShowDescription(true)}
title="Show the description"
className="min-w-0 cursor-pointer truncate text-left text-[15px] font-semibold
tracking-tight"
>
{item.title}
</button>
) : (
<h2 className="min-w-0 truncate text-[15px] font-semibold tracking-tight">
{item.title}
</h2>
)}
<span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
{[compactViews(item.views), relativeTime(item.published)]
.filter(Boolean)
.join(" · ")}
</div>
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{onDownload && (
@@ -575,22 +626,15 @@ export default function Player({
</div>
</div>
{/* Collapsed by default — the description is rarely what you came for. */}
{item.description && (
<details className="group mt-2">
<summary
className="cursor-pointer list-none text-[11px] font-medium text-slate-500
hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"
>
<span className="inline-block transition-transform group-open:rotate-90"></span>{" "}
Description
</summary>
<p className="mt-2 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
{item.description}
</p>
</details>
)}
</footer>
{showDescription && (
<Dialog title={item.title} onCancel={() => setShowDescription(false)} wide>
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
<Linked text={item.description} />
</p>
</Dialog>
)}
</div>
);
}