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:
+64
-20
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user