commit 228c495d3a05aac062ce0c7db849a941dfe9add7
parent 2dbc7d622f624438b2aa16264745aeda82055089
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 20:00:32 -0400
player: the modal is a column with a labelled strip at the bottom
The transcript modal reads top-down again — title, mode, cues, then the
actions — and the actions are lucide icons with words next to them
instead of thirteen unlabelled emoji whose only explanation was a
tooltip no touch device shows. Below sm the column takes the whole
screen under a full-bleed player, so the cue list is 410px instead of
about a hundred. Mini, Hide and Close never scroll out of reach.
The download-format menu moves to a portalled dropdown, since an
absolutely-positioned panel would be clipped by the scrolling strip;
the modal and the player now read their width and top edge from the
same two CSS variables; and sheets move above the player in the stack
so one can open over it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
3 files changed, 338 insertions(+), 183 deletions(-)
diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx
@@ -10,6 +10,8 @@ import {
useReducer,
useRef,
useState,
+ type ComponentProps,
+ type ReactNode,
} from "react";
import type ReactPlayerType from "react-player";
import { fetchTranscript } from "./transcriptCache";
@@ -921,11 +923,17 @@ export function PlayerProvider({
let containerClass = "";
if (displayMode === "modal") {
+ // Width and top edge come from --player-w/--player-top (globals.css), the
+ // same two variables TranscriptModal uses to reserve this slot. Below sm
+ // that is a full-bleed 100vw video tucked under the notch; from sm it is
+ // the centred min(92vw,960px) card it has always been.
containerClass =
- "fixed left-1/2 -translate-x-1/2 top-16 w-[min(92vw,960px)] aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl";
+ "fixed left-1/2 -translate-x-1/2 top-(--player-top) w-(--player-w) aspect-video z-[60] rounded-lg max-sm:rounded-none overflow-hidden bg-black shadow-2xl";
} else if (displayMode === "mini") {
+ // Narrower on a phone (a 288px mini over a 390px viewport buries the page)
+ // and lifted clear of the home indicator.
containerClass =
- "fixed bottom-4 right-4 w-72 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40";
+ "fixed bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] right-4 w-44 sm:w-96 aspect-video z-[60] rounded-lg overflow-hidden bg-black shadow-2xl ring-1 ring-black/40";
} else {
// hidden — keep iframe rendered with size so YT keeps playing,
// but pull it offscreen and remove from interaction.
@@ -1095,7 +1103,7 @@ function HiddenControls({
onClose: () => void;
}) {
return (
- <div className="fixed bottom-4 right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)]">
+ <div className="fixed bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] right-4 z-[60] flex items-center gap-2 rounded-full bg-zinc-900/95 text-white shadow-2xl ring-1 ring-white/10 px-3 py-2 max-w-[min(90vw,28rem)] max-sm:inset-x-3 max-sm:right-auto max-sm:max-w-none">
<span className="text-xs opacity-70 shrink-0">♪</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">
{title}
@@ -1107,35 +1115,74 @@ function HiddenControls({
);
}
+// One control in the player/modal chrome.
+//
+// `title` is BOTH the tooltip and the accessible name (`aria-label`), and every
+// one of those strings is an e2e selector — so a control may gain an icon and a
+// visible label, but its `title` never changes.
+//
+// `icon` + `label` are the modal strip's shape: a lucide glyph with a short word
+// beside it, because a tooltip is invisible on touch and 13 unlabelled emoji are
+// not a control bar. `char` stays for the places that are genuinely too small
+// for a label (the hidden-player pill).
+//
+// Extra props are spread so the button can be a Radix `asChild` trigger (the
+// download-format menu): Slot merges its `aria-expanded`/`data-state`/composed
+// `onClick` into these props, and they have to reach the real <button>.
+type ControlButtonProps = Omit<
+ ComponentProps<"button">,
+ "title" | "children"
+> & {
+ title: string;
+ char?: string;
+ icon?: ReactNode;
+ label?: string;
+ /** Extra classes on the visible label — `max-sm:hidden` for the controls that
+ * have to stay narrow on a phone. The button keeps ONE identity either way;
+ * rendering a second, differently-labelled copy per breakpoint would give
+ * every `getByRole("button", { name })` two matches. */
+ labelClass?: string;
+ highlight?: boolean;
+};
+
function ControlButton({
title,
onClick,
char,
+ icon,
+ label,
+ labelClass,
disabled,
highlight,
-}: {
- title: string;
- onClick: () => void;
- char: string;
- disabled?: boolean;
- highlight?: boolean;
-}) {
+ className,
+ ...rest
+}: ControlButtonProps) {
return (
<button
+ {...rest}
type="button"
title={title}
aria-label={title}
onClick={onClick}
disabled={disabled}
- className={`shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-md text-sm font-mono transition ${
+ className={`shrink-0 inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-mono transition ${
+ label ? "h-10 px-3 sm:h-9 sm:px-2.5" : "w-10 h-10 sm:w-8 sm:h-8"
+ } ${
disabled
? "bg-white/5 text-white/30 cursor-not-allowed"
: highlight
? "bg-amber-500/30 hover:bg-amber-500/50 text-white ring-1 ring-amber-400/60"
: "bg-white/10 hover:bg-white/20 text-white"
- }`}
+ } ${className ?? ""}`}
>
- {char}
+ {icon ?? char}
+ {label && (
+ <span
+ className={`font-sans text-xs whitespace-nowrap ${labelClass ?? ""}`}
+ >
+ {label}
+ </span>
+ )}
</button>
);
}
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -3,12 +3,34 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
+ ArrowLeftToLineIcon,
+ ArrowRightToLineIcon,
+ CheckIcon,
+ ClipboardIcon,
+ DownloadIcon,
+ ExternalLinkIcon,
+ LinkIcon,
+ MessageSquareIcon,
+ MinusIcon,
+ PictureInPicture2Icon,
+ ScrollTextIcon,
+ SparklesIcon,
+ TerminalIcon,
+ XIcon,
+} from "lucide-react";
+import {
ControlButton,
toHMS,
usePlayer,
usePlayerTime,
type DigestStatus,
} from "./PlayerProvider";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
import type { VideoDigest } from "../lib/digests";
import { AgeRestrictedBadge, LivestreamBadge } from "./badges";
import { VirtualRow } from "./VirtualRow";
@@ -59,8 +81,9 @@ export default function TranscriptModal() {
const shareResetRef = useRef<number | null>(null);
const [mdCopied, setMdCopied] = useState(false);
const mdResetRef = useRef<number | null>(null);
+ // Controlled only so the trigger can render its open highlight; dismissal
+ // (outside click, Escape, focus) is the Radix menu's job.
const [downloadOpen, setDownloadOpen] = useState(false);
- const downloadRef = useRef<HTMLDivElement | null>(null);
// Tracks whether the next scrollToIndex should animate. Smooth on
// user-initiated changes (cue click, mode toggle, initial open); instant
// on natural playhead drift so 4 Hz progress ticks don't keep retriggering
@@ -73,28 +96,6 @@ export default function TranscriptModal() {
? (chatCues?.length ?? 0) > 0
: (data?.cues?.length ?? 0) > 0;
- // Close the download format menu on an outside click or Escape.
- useEffect(() => {
- if (!downloadOpen) return;
- const onDown = (e: MouseEvent) => {
- if (
- downloadRef.current &&
- !downloadRef.current.contains(e.target as Node)
- ) {
- setDownloadOpen(false);
- }
- };
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") setDownloadOpen(false);
- };
- document.addEventListener("mousedown", onDown);
- document.addEventListener("keydown", onKey);
- return () => {
- document.removeEventListener("mousedown", onDown);
- document.removeEventListener("keydown", onKey);
- };
- }, [downloadOpen]);
-
// Pre-compute author/body split once per cue array so the render hot path
// doesn't redo `indexOf`/`slice` on every progress tick. Memo key is the
// identity of the underlying cues array.
@@ -154,6 +155,11 @@ export default function TranscriptModal() {
scrollKindRef.current = "auto";
}, [activeIndex, modalOpen, modalMode, rowVirtualizer]);
+ // Lock the page behind the modal. Radix's RemoveScroll (sheets, and the
+ // download menu's modal layer) also writes to <body> — the two never overlap,
+ // because nothing that uses RemoveScroll opens *over* this modal. If that ever
+ // changes, the restore below would hand back a value RemoveScroll had already
+ // replaced.
useEffect(() => {
if (modalOpen) {
const prev = document.body.style.overflow;
@@ -225,6 +231,50 @@ export default function TranscriptModal() {
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
+ const iconSize = "size-4";
+
+ // The mode strip: the same two toggles as before (each falls back to the
+ // transcript), lifted out of the action bar into their own row so the modes
+ // read as a chooser rather than as two more emoji among thirteen. `title` —
+ // and so the accessible name — is unchanged and still depends on the mode.
+ const modeStrip = (
+ <div className="inline-flex w-fit items-center gap-1 rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-1 pointer-events-auto">
+ {/* Only offered where a digest exists. ~0.1% of the corpus is digested,
+ so an always-present control would be a dead end on almost every
+ video. */}
+ {digestAvailable && (
+ <ControlButton
+ title={isDigest ? "Show transcript" : "Show AI chapters"}
+ onClick={onToggleDigest}
+ icon={
+ isDigest ? (
+ <ScrollTextIcon className={iconSize} />
+ ) : (
+ <SparklesIcon className={iconSize} />
+ )
+ }
+ label={isDigest ? "Transcript" : "AI chapters"}
+ char={isDigest ? "\u{1F4DC}" : "\u2726"}
+ highlight={isDigest}
+ />
+ )}
+ <ControlButton
+ title={isChat ? "Show transcript" : "Show live chat"}
+ onClick={onToggleChat}
+ icon={
+ isChat ? (
+ <ScrollTextIcon className={iconSize} />
+ ) : (
+ <MessageSquareIcon className={iconSize} />
+ )
+ }
+ label={isChat ? "Transcript" : "Live chat"}
+ char={isChat ? "\u{1F4DC}" : "\u{1F4AC}"}
+ highlight={isChat}
+ />
+ </div>
+ );
+
return (
<div className="fixed inset-0 z-50 flex flex-col">
<button
@@ -233,156 +283,30 @@ export default function TranscriptModal() {
onClick={() => setDisplayMode("mini")}
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
/>
- <div className="relative flex flex-col items-center w-full h-full overflow-hidden pt-4 pointer-events-none">
+ <div className="relative flex flex-col items-center w-full h-full overflow-hidden pointer-events-none">
+ {/* The column that sits UNDER the player. Its top margin and its height
+ are derived from the same --player-w/--player-top the player itself
+ is positioned with (globals.css), so the two can't drift apart.
+ Below sm it takes the full remaining height, which is what makes the
+ cue list tall and the action strip land on the bottom edge. */}
<div
- className="w-[min(92vw,960px)] pointer-events-auto flex flex-col gap-3"
+ className="w-(--player-w) pointer-events-auto flex flex-col gap-2 sm:gap-3 max-sm:h-full"
style={{
- marginTop: "calc(min(92vw, 960px) * 9 / 16 + 3rem)",
+ marginTop:
+ "calc(var(--player-top) + var(--player-w) * 9 / 16 + 0.75rem)",
maxHeight:
- "calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)",
+ "calc(100dvh - var(--player-top) - var(--player-w) * 9 / 16 - 1.5rem)",
}}
>
- <div className="flex items-center gap-2 flex-wrap rounded-lg bg-zinc-900/80 ring-1 ring-white/10 p-2">
- <ControlButton
- title="Mark clip start at current time"
- onClick={markClipStart}
- char="["
- highlight={clipStart !== null}
- />
- <ControlButton
- title="Mark clip end at current time"
- onClick={markClipEnd}
- char="]"
- highlight={clipEnd !== null}
- />
- <ClipReadout
- clipStart={clipStart}
- clipEnd={clipEnd}
- onClear={clearClip}
- />
- <ControlButton
- title={
- canDownload
- ? copied
- ? "Copied!"
- : "Copy yt-dlp download command for this clip"
- : "Mark both clip start and end first"
- }
- onClick={onCopy}
- char={copied ? "✓" : "↓"}
- disabled={!canDownload}
- />
- <div className="flex-1" />
- {/* Only offered where a digest exists. ~0.1% of the corpus is
- digested, so an always-present control would be a dead end on
- almost every video. */}
- {digestAvailable && (
- <ControlButton
- title={isDigest ? "Show transcript" : "Show AI chapters"}
- onClick={onToggleDigest}
- char={isDigest ? "📜" : "✦"}
- highlight={isDigest}
- />
- )}
- <ControlButton
- title={isChat ? "Show transcript" : "Show live chat"}
- onClick={onToggleChat}
- char={isChat ? "📜" : "💬"}
- highlight={isChat}
- />
- <ControlButton
- title={
- !data
- ? "Loading…"
- : shareCopied
- ? "Copied!"
- : "Copy share link at current time"
- }
- onClick={onShare}
- char={shareCopied ? "✓" : "⤴"}
- disabled={!data}
- />
- <div className="relative" ref={downloadRef}>
- <ControlButton
- title={
- canDownloadFile
- ? `Download this ${isChat ? "live chat" : "transcript"} as a file`
- : "Nothing to download yet"
- }
- onClick={() => setDownloadOpen((v) => !v)}
- char="⤓"
- disabled={!canDownloadFile}
- highlight={downloadOpen}
- />
- {downloadOpen && (
- <div className="absolute right-0 top-9 z-10 flex flex-col overflow-hidden rounded-md bg-zinc-900 shadow-xl ring-1 ring-white/15">
- {(["txt", "srt", "json"] as const).map((fmt) => (
- <button
- key={fmt}
- type="button"
- onClick={() => {
- downloadTranscriptFile(fmt);
- setDownloadOpen(false);
- }}
- className="px-3 py-1.5 text-left font-mono text-xs uppercase tracking-wide text-zinc-200 hover:bg-white/10"
- >
- .{fmt}
- </button>
- ))}
- </div>
- )}
- </div>
- <ControlButton
- title={
- !canDownloadFile
- ? "Nothing to copy yet"
- : mdCopied
- ? "Copied!"
- : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)`
- }
- onClick={async () => {
- const ok = await copyTranscriptMarkdown();
- if (!ok) return;
- setMdCopied(true);
- if (mdResetRef.current) window.clearTimeout(mdResetRef.current);
- mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500);
- }}
- char={mdCopied ? "✓" : "📋"}
- disabled={!canDownloadFile}
- />
- {data?.platform === "youtube" && (
- <ControlButton
- title="Open in Preservetube"
- onClick={openInPreservetube}
- char="⧉"
- />
- )}
- <ControlButton
- title="Collapse to mini-player"
- onClick={() => setDisplayMode("mini")}
- char="↘"
- />
- <ControlButton
- title="Hide player (keep audio playing)"
- onClick={() => setDisplayMode("hidden")}
- char="—"
- />
- <ControlButton
- title="Close player"
- onClick={closePlayer}
- char="✕"
- />
- </div>
-
- <div className="text-white min-w-0">
- <h2 className="text-lg font-semibold leading-tight truncate">
- {data?.title ?? "Loading…"}
+ <div className="text-white min-w-0 px-3 sm:px-0">
+ <h2 className="text-lg font-semibold leading-tight line-clamp-2 sm:truncate">
+ {data?.title ?? "Loading\u2026"}
</h2>
{data && (
<p className="text-xs text-zinc-300 mt-0.5 flex items-center gap-1.5 flex-wrap">
<span>
{formatDate(data.uploadDate)}
- {data.channel && ` · ${data.channel}`}
+ {data.channel && ` \u00b7 ${data.channel}`}
</span>
{data.isLivestream && <LivestreamBadge />}
{data.ageRestricted && <AgeRestrictedBadge />}
@@ -390,17 +314,20 @@ export default function TranscriptModal() {
)}
</div>
+ <div className="px-3 sm:px-0">{modeStrip}</div>
+
{modalNotice && (
<p
role="status"
- className="text-xs text-amber-200 bg-amber-500/15 ring-1 ring-amber-400/40 rounded px-3 py-1.5"
+ className="mx-3 sm:mx-0 text-xs text-amber-200 bg-amber-500/15 ring-1 ring-amber-400/40 rounded px-3 py-1.5"
>
{modalNotice}
</p>
)}
<div
ref={scrollRef}
- className="flex-1 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900"
+ data-testid="cue-list"
+ className="flex-1 min-h-0 overflow-y-auto overscroll-contain touch-pan-y border border-zinc-700 bg-zinc-900 rounded-none sm:rounded-lg"
>
{isDigest ? (
<DigestPanel
@@ -413,7 +340,7 @@ export default function TranscriptModal() {
<>
{cueStatus === "loading" && (
<p className="text-sm text-zinc-400 p-4">
- {isChat ? "Loading live chat…" : "Loading transcript…"}
+ {isChat ? "Loading live chat\u2026" : "Loading transcript\u2026"}
</p>
)}
{cueStatus === "ready" && displayCues.length === 0 && (
@@ -446,12 +373,188 @@ export default function TranscriptModal() {
</>
)}
</div>
+
+ {/* Action strip, pinned to the bottom edge. The clip/share group
+ scrolls sideways when it doesn't fit; the window group never does,
+ so Close is reachable at any width without a horizontal scroll. */}
+ <div className="shrink-0 sticky bottom-0 flex items-stretch gap-2 bg-zinc-900/80 ring-1 ring-white/10 rounded-none sm:rounded-lg p-2 pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))] sm:pb-2">
+ <div className="flex-1 min-w-0 flex items-center gap-2 overflow-x-auto">
+ <ControlButton
+ title="Mark clip start at current time"
+ onClick={markClipStart}
+ icon={<ArrowLeftToLineIcon className={iconSize} />}
+ label="Clip in"
+ char="["
+ highlight={clipStart !== null}
+ />
+ <ControlButton
+ title="Mark clip end at current time"
+ onClick={markClipEnd}
+ icon={<ArrowRightToLineIcon className={iconSize} />}
+ label="Clip out"
+ char="]"
+ highlight={clipEnd !== null}
+ />
+ <ClipReadout
+ clipStart={clipStart}
+ clipEnd={clipEnd}
+ onClear={clearClip}
+ />
+ <ControlButton
+ title={
+ canDownload
+ ? copied
+ ? "Copied!"
+ : "Copy yt-dlp download command for this clip"
+ : "Mark both clip start and end first"
+ }
+ onClick={onCopy}
+ icon={
+ copied ? (
+ <CheckIcon className={iconSize} />
+ ) : (
+ <TerminalIcon className={iconSize} />
+ )
+ }
+ label="yt-dlp"
+ char={copied ? "\u2713" : "\u2193"}
+ disabled={!canDownload}
+ />
+
+ <Divider />
+
+ <ControlButton
+ title={
+ !data
+ ? "Loading\u2026"
+ : shareCopied
+ ? "Copied!"
+ : "Copy share link at current time"
+ }
+ onClick={onShare}
+ icon={
+ shareCopied ? (
+ <CheckIcon className={iconSize} />
+ ) : (
+ <LinkIcon className={iconSize} />
+ )
+ }
+ label="Share"
+ char={shareCopied ? "\u2713" : "\u2934"}
+ disabled={!data}
+ />
+ {/* A portalled menu, because the trigger now lives inside a
+ horizontally scrolling strip: an absolutely-positioned panel
+ would be clipped by that scroller. */}
+ <DropdownMenu open={downloadOpen} onOpenChange={setDownloadOpen}>
+ <DropdownMenuTrigger asChild>
+ <ControlButton
+ title={
+ canDownloadFile
+ ? `Download this ${isChat ? "live chat" : "transcript"} as a file`
+ : "Nothing to download yet"
+ }
+ icon={<DownloadIcon className={iconSize} />}
+ label="Download"
+ char="\u2913"
+ disabled={!canDownloadFile}
+ highlight={downloadOpen}
+ />
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="z-[70] min-w-0">
+ {(["txt", "srt", "json"] as const).map((fmt) => (
+ <DropdownMenuItem
+ key={fmt}
+ onSelect={() => downloadTranscriptFile(fmt)}
+ className="font-mono text-xs uppercase tracking-wide"
+ >
+ .{fmt}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <ControlButton
+ title={
+ !canDownloadFile
+ ? "Nothing to copy yet"
+ : mdCopied
+ ? "Copied!"
+ : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)`
+ }
+ onClick={async () => {
+ const ok = await copyTranscriptMarkdown();
+ if (!ok) return;
+ setMdCopied(true);
+ if (mdResetRef.current) window.clearTimeout(mdResetRef.current);
+ mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500);
+ }}
+ icon={
+ mdCopied ? (
+ <CheckIcon className={iconSize} />
+ ) : (
+ <ClipboardIcon className={iconSize} />
+ )
+ }
+ label="Copy MD"
+ char={mdCopied ? "\u2713" : "\u{1F4CB}"}
+ disabled={!canDownloadFile}
+ />
+ {data?.platform === "youtube" && (
+ <ControlButton
+ title="Open in Preservetube"
+ onClick={openInPreservetube}
+ icon={<ExternalLinkIcon className={iconSize} />}
+ label="Preservetube"
+ char="\u29c9"
+ />
+ )}
+ </div>
+
+ <Divider />
+
+ {/* Mini / Hide / Close: the labels drop away on a phone so the
+ group stays narrow enough to sit beside the scroller — Close is
+ then reachable at any width without a sideways scroll. One
+ button each; a per-breakpoint duplicate would give every
+ `getByRole("button", { name })` two matches. */}
+ <div className="shrink-0 flex items-center gap-1 sm:gap-2">
+ <ControlButton
+ title="Collapse to mini-player"
+ onClick={() => setDisplayMode("mini")}
+ icon={<PictureInPicture2Icon className={iconSize} />}
+ label="Mini"
+ labelClass="max-sm:hidden"
+ char="\u2198"
+ />
+ <ControlButton
+ title="Hide player (keep audio playing)"
+ onClick={() => setDisplayMode("hidden")}
+ icon={<MinusIcon className={iconSize} />}
+ label="Hide"
+ labelClass="max-sm:hidden"
+ char="\u2014"
+ />
+ <ControlButton
+ title="Close player"
+ onClick={closePlayer}
+ icon={<XIcon className={iconSize} />}
+ label="Close"
+ labelClass="max-sm:hidden"
+ char="\u2715"
+ />
+ </div>
+ </div>
</div>
</div>
</div>
);
}
+// A hairline between control groups in the action strip.
+function Divider() {
+ return <div className="w-px self-stretch shrink-0 bg-white/10" />;
+}
+
// The digest panel: chapters as a plain (non-virtualized) list, the topic tags,
// a provenance line, and — when the digest was borrowed from another video — a
// prominent notice saying so.
@@ -639,13 +742,13 @@ function ClipReadout({
}) {
if (clipStart === null && clipEnd === null) {
return (
- <span className="text-xs text-zinc-500 font-mono px-1">
+ <span className="shrink-0 whitespace-nowrap text-xs text-zinc-500 font-mono px-1">
no clip set
</span>
);
}
return (
- <span className="text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1">
+ <span className="shrink-0 whitespace-nowrap text-xs font-mono text-zinc-200 px-1 inline-flex items-center gap-1">
<span>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span>
<span className="opacity-60">→</span>
<span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span>
diff --git a/common/components/ui/sheet.tsx b/common/components/ui/sheet.tsx
@@ -36,7 +36,11 @@ function SheetOverlay({
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
- "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
+ // z-[70], not z-50: the floating player is z-[60] (PlayerProvider) and
+ // the transcript modal z-50. A sheet has to open OVER the player —
+ // otherwise the mini player sits on top of it and, with Radix's
+ // `pointer-events: none` on <body>, nothing in the sheet is clickable.
+ "fixed inset-0 z-[70] bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -60,7 +64,8 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
- "fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
+ // See SheetOverlay: above the z-[60] player, not below it.
+ "fixed z-[70] flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&