commit 79446981bf0273369ca05877c001d171ada3bcb5
parent b87bd2b87a99a17e7053546969973d146e24582a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 16 Apr 2026 13:43:43 -0400
Add modal control buttons and clipping feature
Diffstat:
2 files changed, 315 insertions(+), 55 deletions(-)
diff --git a/app/PlayerProvider.tsx b/app/PlayerProvider.tsx
@@ -36,6 +36,7 @@ export type TranscriptData = TranscriptSummary & {
};
type Status = "idle" | "loading" | "ready";
+export type DisplayMode = "modal" | "mini" | "hidden";
type Detail = {
slug: string;
@@ -43,15 +44,29 @@ type Detail = {
cues?: Cue[];
};
+type ClipState = {
+ slug: string | null;
+ start: number | null;
+ end: number | null;
+};
+
type PlayerState = {
activeSlug: string | null;
data: TranscriptData | null;
status: Status;
+ displayMode: DisplayMode;
modalOpen: boolean;
playing: boolean;
currentTime: number;
+ clipStart: number | null;
+ clipEnd: number | null;
openTranscript: (slug: string, start?: number) => void;
- closeModal: () => void;
+ setDisplayMode: (mode: DisplayMode) => void;
+ closePlayer: () => void;
+ markClipStart: () => void;
+ markClipEnd: () => void;
+ clearClip: () => void;
+ copyDownloadCommand: () => Promise<boolean>;
seekTo: (seconds: number) => void;
};
@@ -63,6 +78,14 @@ export function usePlayer(): PlayerState {
return v;
}
+function toHMS(seconds: number): string {
+ const s = Math.max(0, Math.floor(seconds));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+}
+
export function PlayerProvider({
children,
transcripts,
@@ -72,7 +95,15 @@ export function PlayerProvider({
}) {
const { v: urlSlug, t: urlTime } = useUrlParams();
const [detail, setDetail] = useState<Detail | null>(null);
- const [dismissedSlug, setDismissedSlug] = useState<string | null>(null);
+ const [displayState, setDisplayState] = useState<{
+ slug: string;
+ mode: DisplayMode;
+ } | null>(null);
+ const [clip, setClip] = useState<ClipState>({
+ slug: null,
+ start: null,
+ end: null,
+ });
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const playerRef = useRef<ReactPlayerType | null>(null);
@@ -86,23 +117,30 @@ export function PlayerProvider({
}, [transcripts]);
const activeSlug = urlSlug;
- const modalOpen = activeSlug !== null && dismissedSlug !== activeSlug;
+ const displayMode: DisplayMode =
+ displayState?.slug === activeSlug ? displayState.mode : "modal";
+ const modalOpen = activeSlug !== null && displayMode === "modal";
const summary = activeSlug ? bySlug.get(activeSlug) ?? null : null;
const detailMatches = detail?.slug === activeSlug;
- const data: TranscriptData | null = summary
- ? detailMatches
- ? { ...summary, description: detail.description, cues: detail.cues }
- : { ...summary }
- : null;
+ const data: TranscriptData | null = useMemo(() => {
+ if (!summary) return null;
+ if (detailMatches && detail) {
+ return { ...summary, description: detail.description, cues: detail.cues };
+ }
+ return { ...summary };
+ }, [summary, detail, detailMatches]);
const status: Status = !activeSlug
? "idle"
: detailMatches
? "ready"
: "loading";
+ const clipStart = clip.slug && clip.slug === activeSlug ? clip.start : null;
+ const clipEnd = clip.slug && clip.slug === activeSlug ? clip.end : null;
+
const openTranscript = useCallback(
(slug: string, start?: number) => {
- setDismissedSlug(null);
+ setDisplayState({ slug, mode: "modal" });
const t = typeof start === "number" ? Math.round(start) : null;
if (slug === activeSlug && t !== null && playerRef.current) {
playerRef.current.seekTo(t, "seconds");
@@ -113,9 +151,56 @@ export function PlayerProvider({
[activeSlug],
);
- const closeModal = useCallback(() => {
- setDismissedSlug(activeSlug);
- }, [activeSlug]);
+ const setDisplayMode = useCallback(
+ (mode: DisplayMode) => {
+ if (!activeSlug) return;
+ setDisplayState({ slug: activeSlug, mode });
+ },
+ [activeSlug],
+ );
+
+ const closePlayer = useCallback(() => {
+ setDisplayState(null);
+ writeUrlParams({ v: null, t: null });
+ }, []);
+
+ const markClipStart = useCallback(() => {
+ if (!activeSlug) return;
+ setClip((prev) => ({
+ slug: activeSlug,
+ start: currentTime,
+ end: prev.slug === activeSlug ? prev.end : null,
+ }));
+ }, [activeSlug, currentTime]);
+
+ const markClipEnd = useCallback(() => {
+ if (!activeSlug) return;
+ setClip((prev) => ({
+ slug: activeSlug,
+ start: prev.slug === activeSlug ? prev.start : null,
+ end: currentTime,
+ }));
+ }, [activeSlug, currentTime]);
+
+ const clearClip = useCallback(() => {
+ setClip({ slug: null, start: null, end: null });
+ }, []);
+
+ const copyDownloadCommand = useCallback(async (): Promise<boolean> => {
+ if (!data) return false;
+ const url = `https://www.youtube.com/watch?v=${data.id}`;
+ const sections =
+ clipStart !== null && clipEnd !== null
+ ? `--download-sections "*${toHMS(clipStart)}-${toHMS(clipEnd)}" `
+ : "";
+ const cmd = `yt-dlp ${sections}"${url}"`;
+ try {
+ await navigator.clipboard.writeText(cmd);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [data, clipStart, clipEnd]);
const seekTo = useCallback((seconds: number) => {
if (playerRef.current) {
@@ -161,11 +246,11 @@ export function PlayerProvider({
useEffect(() => {
if (!modalOpen) return;
const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") closeModal();
+ if (e.key === "Escape") setDisplayMode("mini");
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [modalOpen, closeModal]);
+ }, [modalOpen, setDisplayMode]);
const handleReady = () => {
if (
@@ -184,32 +269,44 @@ export function PlayerProvider({
activeSlug,
data,
status,
+ displayMode,
modalOpen,
playing,
currentTime,
+ clipStart,
+ clipEnd,
openTranscript,
- closeModal,
+ setDisplayMode,
+ closePlayer,
+ markClipStart,
+ markClipEnd,
+ clearClip,
+ copyDownloadCommand,
seekTo,
};
- const playerMode: "hidden" | "mini" | "modal" = !activeSlug
- ? "hidden"
- : modalOpen
- ? "modal"
- : "mini";
+ const showPlayer = activeSlug !== null && data !== null;
+
+ let containerClass = "";
+ if (displayMode === "modal") {
+ 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";
+ } else if (displayMode === "mini") {
+ 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";
+ } else {
+ // hidden — keep iframe rendered with size so YT keeps playing,
+ // but pull it offscreen and remove from interaction.
+ containerClass =
+ "fixed -left-[10000px] -top-[10000px] w-80 aspect-video pointer-events-none";
+ }
return (
<Ctx.Provider value={value}>
{children}
- {playerMode !== "hidden" && activeSlug && data && (
- <div
- className={
- playerMode === "modal"
- ? "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 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"
- }
- >
+ {showPlayer && (
+ <div className={containerClass} aria-hidden={displayMode === "hidden"}>
<ReactPlayer
ref={(p: ReactPlayerType | null) => {
playerRef.current = p;
@@ -226,11 +323,11 @@ export function PlayerProvider({
setCurrentTime(s.playedSeconds)
}
/>
- {playerMode === "mini" && (
+ {displayMode === "mini" && (
<button
type="button"
aria-label="Reopen transcript"
- onClick={() => setDismissedSlug(null)}
+ onClick={() => setDisplayMode("modal")}
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent text-white text-xs px-3 py-1.5 text-left hover:from-black/95"
>
<span className="font-medium line-clamp-1">{data.title}</span>
@@ -239,6 +336,74 @@ export function PlayerProvider({
)}
</div>
)}
+
+ {showPlayer && displayMode === "hidden" && (
+ <HiddenControls
+ title={data.title}
+ onShow={() => setDisplayMode("modal")}
+ onMini={() => setDisplayMode("mini")}
+ onClose={closePlayer}
+ />
+ )}
</Ctx.Provider>
);
}
+
+function HiddenControls({
+ title,
+ onShow,
+ onMini,
+ onClose,
+}: {
+ title: string;
+ onShow: () => void;
+ onMini: () => void;
+ 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)]">
+ <span className="text-xs opacity-70 shrink-0">♪</span>
+ <span className="text-xs font-medium truncate flex-1 min-w-0">
+ {title}
+ </span>
+ <ControlButton title="Show player" onClick={onShow} char="▢" />
+ <ControlButton title="Collapse to mini-player" onClick={onMini} char="↘" />
+ <ControlButton title="Close player" onClick={onClose} char="✕" />
+ </div>
+ );
+}
+
+function ControlButton({
+ title,
+ onClick,
+ char,
+ disabled,
+ highlight,
+}: {
+ title: string;
+ onClick: () => void;
+ char: string;
+ disabled?: boolean;
+ highlight?: boolean;
+}) {
+ return (
+ <button
+ 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 ${
+ 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"
+ }`}
+ >
+ {char}
+ </button>
+ );
+}
+
+export { ControlButton, toHMS };
diff --git a/app/TranscriptModal.tsx b/app/TranscriptModal.tsx
@@ -1,7 +1,7 @@
"use client";
-import { useEffect, useRef } from "react";
-import { usePlayer } from "./PlayerProvider";
+import { useEffect, useRef, useState } from "react";
+import { ControlButton, toHMS, usePlayer } from "./PlayerProvider";
import { formatTimestamp } from "@/lib/vtt";
import { formatDate } from "@/lib/format";
@@ -12,11 +12,20 @@ export default function TranscriptModal() {
data,
status,
currentTime,
- closeModal,
+ clipStart,
+ clipEnd,
+ setDisplayMode,
+ closePlayer,
+ markClipStart,
+ markClipEnd,
+ clearClip,
+ copyDownloadCommand,
seekTo,
} = usePlayer();
const activeRef = useRef<HTMLLIElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
+ const [copied, setCopied] = useState(false);
+ const copyResetRef = useRef<number | null>(null);
const cues = data?.cues ?? [];
const activeIndex = findActiveIndex(cues, currentTime);
@@ -36,14 +45,29 @@ export default function TranscriptModal() {
}
}, [modalOpen]);
+ useEffect(() => {
+ return () => {
+ if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current);
+ };
+ }, []);
+
if (!modalOpen || !activeSlug) return null;
+ const canDownload = clipStart !== null && clipEnd !== null && clipEnd > clipStart;
+ const onCopy = async () => {
+ const ok = await copyDownloadCommand();
+ if (!ok) return;
+ setCopied(true);
+ if (copyResetRef.current !== null) window.clearTimeout(copyResetRef.current);
+ copyResetRef.current = window.setTimeout(() => setCopied(false), 1800);
+ };
+
return (
<div className="fixed inset-0 z-50 flex flex-col">
<button
type="button"
- aria-label="Close"
- onClick={closeModal}
+ aria-label="Collapse to mini-player"
+ 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">
@@ -55,25 +79,64 @@ export default function TranscriptModal() {
"calc(100vh - (min(92vw, 960px) * 9 / 16) - 5rem)",
}}
>
- <div className="flex items-start justify-between gap-3 text-white">
- <div className="min-w-0">
- <h2 className="text-lg font-semibold leading-tight truncate">
- {data?.title ?? "Loading…"}
- </h2>
- {data && (
- <p className="text-xs text-zinc-300 mt-0.5">
- {formatDate(data.uploadDate)}
- {data.channel && ` · ${data.channel}`}
- </p>
- )}
- </div>
- <button
- type="button"
- onClick={closeModal}
- className="shrink-0 rounded-md bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 text-sm"
- >
- Close
- </button>
+ <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" />
+ <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…"}
+ </h2>
+ {data && (
+ <p className="text-xs text-zinc-300 mt-0.5">
+ {formatDate(data.uploadDate)}
+ {data.channel && ` · ${data.channel}`}
+ </p>
+ )}
</div>
<div
@@ -120,6 +183,39 @@ export default function TranscriptModal() {
);
}
+function ClipReadout({
+ clipStart,
+ clipEnd,
+ onClear,
+}: {
+ clipStart: number | null;
+ clipEnd: number | null;
+ onClear: () => void;
+}) {
+ if (clipStart === null && clipEnd === null) {
+ return (
+ <span className="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>{clipStart !== null ? toHMS(clipStart) : "--:--:--"}</span>
+ <span className="opacity-60">→</span>
+ <span>{clipEnd !== null ? toHMS(clipEnd) : "--:--:--"}</span>
+ <button
+ type="button"
+ title="Clear clip"
+ onClick={onClear}
+ className="ml-1 text-zinc-400 hover:text-white"
+ >
+ ×
+ </button>
+ </span>
+ );
+}
+
function findActiveIndex(
cues: { start: number }[],
t: number,
@@ -139,4 +235,3 @@ function findActiveIndex(
}
return found;
}
-