commit 8119cfacbdbd3870d80a509df9e5ed9066222639
parent 41f43b3ba1471294a0dde8ef3a0295d279b1ce12
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 21:56:14 -0400
export: the transcript modal's Download / Copy MD / yt-dlp controls follow transcriptDownloads
PlayerProvider takes an optional features prop (default all on) into its
context; TranscriptModal renders none of the three controls when
transcriptDownloads is off. Share, the clip marks and every accessible name
are unchanged when on. The export mounts pass the flag from currentSite():
the workspace layout into SiteWorkspace, the duplicates page directly, and
the hub pages (HubHome, AskHub) from their server parents. The editor mounts
no PlayerProvider, so it is untouched.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 140 insertions(+), 77 deletions(-)
diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx
@@ -109,8 +109,20 @@ type ClipState = {
end: number | null;
};
+// Per-mount switches for the modal's optional surfaces. The export site derives
+// them from its site.json (`transcriptDownloads`); the editor mounts nothing
+// and every host that passes nothing keeps everything on.
+export type PlayerFeatures = {
+ // The three per-video export controls: Copy download command, the Download
+ // menu (txt / srt / json) and Copy MD.
+ transcriptDownloads: boolean;
+};
+
+export const DEFAULT_PLAYER_FEATURES: PlayerFeatures = { transcriptDownloads: true };
+
type PlayerState = {
activeSlug: string | null;
+ features: PlayerFeatures;
data: TranscriptData | null;
status: Status;
displayMode: DisplayMode;
@@ -361,8 +373,10 @@ function coreReducer(state: Core, action: CoreAction): Core {
export function PlayerProvider({
children,
+ features = DEFAULT_PLAYER_FEATURES,
}: {
children: React.ReactNode;
+ features?: PlayerFeatures;
}) {
const { v: urlSlug, t: urlTime, vm: urlVm } = useUrlParams();
const [core, dispatch] = useReducer(coreReducer, INITIAL_CORE);
@@ -858,9 +872,18 @@ export function PlayerProvider({
}
};
+ // Keyed on the flag, not the object: a mount site passing an inline
+ // `features={{…}}` must not rebuild the context value on every render.
+ const transcriptDownloads = features.transcriptDownloads;
+ const stableFeatures: PlayerFeatures = useMemo(
+ () => ({ transcriptDownloads }),
+ [transcriptDownloads],
+ );
+
const value: PlayerState = useMemo(
() => ({
activeSlug,
+ features: stableFeatures,
data,
status,
displayMode,
@@ -890,6 +913,7 @@ export function PlayerProvider({
}),
[
activeSlug,
+ stableFeatures,
data,
status,
displayMode,
diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx
@@ -72,7 +72,11 @@ export default function TranscriptModal() {
copyTranscriptMarkdown,
openInPreservetube,
seekTo,
+ features,
} = usePlayer();
+ // Off on a site whose site.json sets `transcriptDownloads: false`: the three
+ // per-video export controls are not rendered at all (Share stays).
+ const showDownloads = features.transcriptDownloads;
const { currentTime } = usePlayerTime();
const scrollRef = useRef<HTMLDivElement | null>(null);
const [copied, setCopied] = useState(false);
@@ -400,26 +404,28 @@ export default function TranscriptModal() {
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}
- />
+ {showDownloads && (
+ <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 />
@@ -446,59 +452,63 @@ export default function TranscriptModal() {
{/* 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>
+ {showDownloads && (
+ <>
+ <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
- ? `Download this ${isChat ? "live chat" : "transcript"} as a file`
- : "Nothing to download yet"
+ !canDownloadFile
+ ? "Nothing to copy yet"
+ : mdCopied
+ ? "Copied!"
+ : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)`
}
- icon={<DownloadIcon className={iconSize} />}
- label="Download"
- char="\u2913"
+ 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}
- 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"
diff --git a/export/app/(workspace)/SiteWorkspace.tsx b/export/app/(workspace)/SiteWorkspace.tsx
@@ -21,11 +21,14 @@ import WorkspaceNav from "./WorkspaceNav";
export default function SiteWorkspace({
children,
+ transcriptDownloads,
}: {
children: React.ReactNode;
+ // From the layout's currentSite() — see WorkspaceLayout.
+ transcriptDownloads: boolean;
}) {
return (
- <PlayerProvider>
+ <PlayerProvider features={{ transcriptDownloads }}>
<SingleSiteDataProvider>
<SearchSessionProvider>
{/* The view provider wraps BOTH: the [Search|Chat|Split] control is
diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx
@@ -45,7 +45,7 @@ export default function AskPage() {
(WorkspaceView), so this route renders only its intro header — the shell
slots it above the chat. Hub mode bypasses the shell, so AskHub renders
here with its own MultiSiteDataProvider wiring. */}
- {isHub && <AskHub />}
+ {isHub && <AskHub transcriptDownloads={site.transcriptDownloads !== false} />}
</div>
);
}
diff --git a/export/app/(workspace)/layout.tsx b/export/app/(workspace)/layout.tsx
@@ -1,4 +1,5 @@
import { instanceMode } from "../lib/mode";
+import { currentSite } from "../lib/site";
import SiteWorkspace from "./SiteWorkspace";
// Shared shell for the two workspace routes — `/` (results) and `/ask` (chat).
@@ -14,5 +15,11 @@ export default function WorkspaceLayout({
children: React.ReactNode;
}) {
if (instanceMode() === "hub") return <>{children}</>;
- return <SiteWorkspace>{children}</SiteWorkspace>;
+ // site.json `transcriptDownloads: false` hides the transcript modal's
+ // per-video export controls (absent = on).
+ return (
+ <SiteWorkspace transcriptDownloads={currentSite().transcriptDownloads !== false}>
+ {children}
+ </SiteWorkspace>
+ );
}
diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx
@@ -9,7 +9,9 @@ export default async function Home() {
// summaries manifest (there is none), so branch before countTranscripts().
// The workspace layout passes hub children straight through, so <HubHome/>
// stays fully self-contained (its own PlayerProvider + MultiSiteDataProvider).
- if (instanceMode() === "hub") return <HubHome />;
+ if (instanceMode() === "hub") {
+ return <HubHome transcriptDownloads={currentSite().transcriptDownloads !== false} />;
+ }
const settings = currentSite();
const count = await countTranscripts();
diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx
@@ -16,7 +16,14 @@ import { SearchSessionProvider } from "yt-dlp-transcript-common/components/Searc
import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry";
import AskChat from "./AskChat";
-export default function AskHub() {
+// `transcriptDownloads` comes from the server parent's currentSite() (a client
+// component cannot read site.json): false hides the modal's per-video export
+// controls.
+export default function AskHub({
+ transcriptDownloads = true,
+}: {
+ transcriptDownloads?: boolean;
+}) {
const { sites } = useRegistry();
const federated = useMemo<FederatedSite[]>(
() =>
@@ -39,7 +46,7 @@ export default function AskHub() {
// took the whole route with it — which is why e2e:2origin was red. The fix is
// the stack, never an opt-out of prerendering.
return (
- <PlayerProvider>
+ <PlayerProvider features={{ transcriptDownloads }}>
<MultiSiteDataProvider sites={federated}>
<SearchSessionProvider>
<AskChat />
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -19,7 +19,14 @@ import ArchiveShelf from "./ArchiveShelf";
import HubStats from "./HubStats";
import HubOfflineManager from "./HubOfflineManager";
-export default function HubHome() {
+// `transcriptDownloads` comes from the server parent's currentSite() (a client
+// component cannot read site.json): false hides the modal's per-video export
+// controls.
+export default function HubHome({
+ transcriptDownloads = true,
+}: {
+ transcriptDownloads?: boolean;
+}) {
const { sites } = useRegistry();
// Carry each site's accent through to the merged search for provenance.
@@ -34,7 +41,7 @@ export default function HubHome() {
);
return (
- <PlayerProvider>
+ <PlayerProvider features={{ transcriptDownloads }}>
<div className="flex flex-col gap-6">
<ArchiveShelf />
<MultiSiteDataProvider sites={federated}>
diff --git a/export/app/duplicates/page.tsx b/export/app/duplicates/page.tsx
@@ -2,13 +2,16 @@ import type { Metadata } from "next";
import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
import PostModal from "yt-dlp-transcript-common/components/PostModal";
+import { currentSite } from "../lib/site";
import { DuplicatesClient } from "./DuplicatesClient";
export const metadata: Metadata = { title: "Duplicates" };
export default function DuplicatesPage() {
return (
- <PlayerProvider>
+ <PlayerProvider
+ features={{ transcriptDownloads: currentSite().transcriptDownloads !== false }}
+ >
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Duplicate shorts</h1>