Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mapp/PlayerProvider.tsx | 225++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mapp/TranscriptModal.tsx | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
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; } -