Files
walkup/frontend/src/pages/LibraryPage.tsx
2026-04-22 06:46:23 -05:00

1463 lines
49 KiB
TypeScript

import { FormEvent, useEffect, useRef, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import WaveSurfer from "wavesurfer.js";
import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
import { api } from "../api/client";
import type { AudioAsset, AudioClip } from "../api/types";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { queryClient } from "../lib/queryClient";
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
import { formatMemberName } from "../lib/teamsnapHelpers";
const MEDIA_ACCEPT =
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
const DEFAULT_CLIP_LENGTH_MS = 30_000;
const TRIM_NUDGE_MS = 100;
const TRIM_STEP_MS = 100;
const TRIM_ZOOM_WINDOW_MS = 3_000;
const TRIM_ZOOM_SLIDER_MAX = 100;
type WalkupClipSourceMode = "upload" | "url" | "existing";
type WalkupClipModalState =
| { mode: "create" }
| { mode: "edit"; clip: AudioClip };
type BootstrapIconName = "play" | "stop" | "three-dots-vertical" | "pencil-square" | "plus-lg" | "x-lg";
type TrimFocusEdge = "start" | "end";
type SourceCreationProgress = {
label: string;
detail: string;
percent: number | null;
};
export function LibraryPage() {
const walkup = useWalkupContext();
const teamId = walkup.selectedTeamId;
const playerId = walkup.currentPlayerId;
const [walkupClipModal, setWalkupClipModal] = useState<WalkupClipModalState | null>(null);
const [manageMediaOpen, setManageMediaOpen] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const previewClipIdRef = useRef<number | null>(null);
const previewRangeRef = useRef<{ startMs: number; endMs: number } | null>(null);
const [previewClipId, setPreviewClipId] = useState<number | null>(null);
const [previewTimeMs, setPreviewTimeMs] = useState<number | null>(null);
const assetsQuery = useQuery({
queryKey: ["assets", teamId, playerId],
queryFn: () => api.listAssets(teamId, playerId),
enabled: Boolean(teamId && playerId),
});
const clipsQuery = useQuery({
queryKey: ["clips", teamId, playerId],
queryFn: () => api.listClips(teamId, playerId),
enabled: Boolean(teamId && playerId),
});
useEffect(() => {
return () => {
stopPreview();
};
}, []);
const deleteClipMutation = useMutation({
mutationFn: (clipId: number) => api.deleteClip(clipId, playerId),
onSuccess: async () => {
stopPreview();
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
},
});
function getAudio() {
const audio = audioRef.current ?? new Audio();
if (!audioRef.current) {
audio.preload = "auto";
audio.onended = () => {
stopPreview();
};
audio.ontimeupdate = () => {
const range = previewRangeRef.current;
if (!range) {
return;
}
setPreviewTimeMs(Math.round(audio.currentTime * 1000));
if (audio.currentTime >= range.endMs / 1000) {
stopPreview();
}
};
}
audioRef.current = audio;
return audio;
}
function stopPreview() {
previewClipIdRef.current = null;
previewRangeRef.current = null;
setPreviewClipId(null);
setPreviewTimeMs(null);
const audio = audioRef.current;
if (!audio) {
return;
}
audio.pause();
audio.currentTime = 0;
audio.removeAttribute("src");
audio.load();
}
async function playPreview(
clip: AudioClip,
startMsOverride?: number,
endMsOverride?: number,
) {
if (!clip.normalized_url) {
return;
}
const audio = getAudio();
const startMs = startMsOverride ?? clip.start_ms;
const endMs = endMsOverride ?? clip.end_ms;
const startSeconds = startMs / 1000;
if (previewClipIdRef.current === clip.id && !audio.paused) {
stopPreview();
return;
}
stopPreview();
const nextAudio = getAudio();
previewClipIdRef.current = clip.id;
previewRangeRef.current = { startMs, endMs };
setPreviewClipId(clip.id);
setPreviewTimeMs(startMs);
nextAudio.pause();
const metadataReady = new Promise<void>((resolve) => {
nextAudio.onloadedmetadata = () => {
if (previewClipIdRef.current === clip.id) {
nextAudio.currentTime = startSeconds;
setPreviewTimeMs(startMs);
}
resolve();
};
});
nextAudio.src = `${API_BASE}${clip.normalized_url}`;
await metadataReady;
try {
await nextAudio.play();
} catch (error) {
stopPreview();
throw error;
}
}
function openCreateWalkupClip() {
setWalkupClipModal({ mode: "create" });
}
function closeCreateWalkupClip() {
stopPreview();
setWalkupClipModal(null);
}
function openEditWalkupClip(clip: AudioClip) {
setWalkupClipModal({ mode: "edit", clip });
}
function openManageMedia() {
setManageMediaOpen(true);
}
function closeManageMedia() {
stopPreview();
setManageMediaOpen(false);
}
if (!walkup.isTeamSnap) {
return <section className="page-grid"><div className="panel">Reconnect with TeamSnap to manage walkup clips.</div></section>;
}
if (!teamId || !playerId) {
return (
<section className="page-grid">
<div className="panel">No player record was found on the selected team, so this account cannot add walkup clips yet.</div>
</section>
);
}
return (
<section className="page-grid">
<div className="panel">
<div className="section-header clip-list-header">
<div>
<h2>My Clips</h2>
<div className="panel-subtitle">{walkup.currentPlayer ? formatMemberName(walkup.currentPlayer) : "Selected Player"}</div>
</div>
<button
type="button"
className="btn btn-primary clip-list-add-button"
onClick={() => openCreateWalkupClip()}
aria-label="Create walk up clip"
title="Create walk up clip"
>
<BootstrapIcon name="plus-lg" />
</button>
</div>
<div className="stack">
{clipsQuery.data?.map((clip) => (
<WalkupClipCard
key={clip.id}
clip={clip}
isPreviewing={previewClipId === clip.id}
onPreview={() => void playPreview(clip)}
onEdit={() => openEditWalkupClip(clip)}
onStopPreview={stopPreview}
/>
))}
{!clipsQuery.isLoading && !clipsQuery.data?.length ? (
<div className="muted">No walkup clips created yet. Open the modal to make the first one.</div>
) : null}
{deleteClipMutation.error instanceof Error ? <div className="muted">{deleteClipMutation.error.message}</div> : null}
</div>
</div>
<div className="panel">
<div className="section-header">
<h2>Uploaded media</h2>
<div className="muted">Review the source files behind your walkup clips. You can rename or delete uploads here.</div>
</div>
<div className="row walkup-panel-actions">
<button type="button" className="btn btn-outline-secondary" onClick={openManageMedia}>
Manage uploaded media
</button>
</div>
</div>
{walkupClipModal ? (
<WalkupClipModal
state={walkupClipModal}
assets={assetsQuery.data ?? []}
teamId={teamId}
playerId={playerId}
previewTimeMs={previewTimeMs}
playPreview={playPreview}
onClose={closeCreateWalkupClip}
stopPreview={stopPreview}
onDeleteClip={async (clipId: number) => {
stopPreview();
await deleteClipMutation.mutateAsync(clipId);
}}
isDeletingClip={deleteClipMutation.isPending}
/>
) : null}
{manageMediaOpen ? (
<ManageUploadedMediaModal
assets={assetsQuery.data ?? []}
teamId={teamId}
playerId={playerId}
onClose={closeManageMedia}
stopPreview={stopPreview}
/>
) : null}
</section>
);
}
function BootstrapIcon({ name }: { name: BootstrapIconName }) {
if (name === "play") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M10.804 8 5 4.633v6.734zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696z" />
</svg>
);
}
if (name === "stop") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5z" />
</svg>
);
}
if (name === "three-dots-vertical") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M9.5 1a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0m0 7a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0m0 7a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0" />
</svg>
);
}
if (name === "plus-lg") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M8 2a.75.75 0 0 1 .75.75v4.5h4.5a.75.75 0 0 1 0 1.5h-4.5v4.5a.75.75 0 0 1-1.5 0v-4.5h-4.5a.75.75 0 0 1 0-1.5h4.5v-4.5A.75.75 0 0 1 8 2" />
</svg>
);
}
if (name === "x-lg") {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M2.146 2.146a.5.5 0 0 1 .708 0L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854a.5.5 0 0 1 0-.708" />
</svg>
);
}
return (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M15.502 1.94a.5.5 0 0 1 0 .706l-.853.853-2-2 .853-.853a.5.5 0 0 1 .707 0l1.293 1.294zm-1.75 1.457-2-2L4.939 8.21a.5.5 0 0 0-.11.168l-1 3a.5.5 0 0 0 .64.64l3-1a.5.5 0 0 0 .168-.11l6.115-6.51zM4.257 11.518l.924-.308 6.563-6.992-1.5-1.5L3.25 9.7l-.308.924 1.315.894z" />
</svg>
);
}
function WalkupClipModal({
state,
assets,
teamId,
playerId,
previewTimeMs,
playPreview,
onClose,
stopPreview,
onDeleteClip,
isDeletingClip,
}: {
state: WalkupClipModalState;
assets: AudioAsset[];
teamId: string;
playerId: string;
previewTimeMs: number | null;
playPreview: (clip: AudioClip, startMs?: number, endMs?: number) => Promise<void>;
onClose: () => void;
stopPreview: () => void;
onDeleteClip: (clipId: number) => Promise<void>;
isDeletingClip: boolean;
}) {
const isCreateMode = state.mode === "create";
const [step, setStep] = useState<"source" | "editor">(isCreateMode ? "source" : "editor");
const [sourceMode, setSourceMode] = useState<WalkupClipSourceMode>("upload");
const [sourceTitle, setSourceTitle] = useState("");
const [draftLabel, setDraftLabel] = useState(state.mode === "edit" ? state.clip.label ?? "" : "");
const [file, setFile] = useState<File | null>(null);
const [fileInputKey, setFileInputKey] = useState(0);
const [importUrl, setImportUrl] = useState("");
const [existingAssetId, setExistingAssetId] = useState("");
const [draftClip, setDraftClip] = useState<AudioClip | null>(state.mode === "edit" ? state.clip : null);
const [draftStartMs, setDraftStartMs] = useState(state.mode === "edit" ? state.clip.start_ms : 0);
const [draftEndMs, setDraftEndMs] = useState(state.mode === "edit" ? state.clip.end_ms : DEFAULT_CLIP_LENGTH_MS);
const [sourceProgress, setSourceProgress] = useState<SourceCreationProgress | null>(null);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
if (state.mode === "edit") {
setStep("editor");
setDraftClip(state.clip);
setDraftStartMs(state.clip.start_ms);
setDraftEndMs(state.clip.end_ms);
setDraftLabel(state.clip.label ?? "");
}
}, [state]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
stopPreview();
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, stopPreview]);
const resolveCreatedClip = async (assetId: number) => {
setSourceProgress({
label: "Loading clip",
detail: "Refreshing the library with the generated walkup clip.",
percent: null,
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
]);
const refreshedClips = await queryClient.fetchQuery({
queryKey: ["clips", teamId, playerId],
queryFn: () => api.listClips(teamId, playerId),
});
const createdClip = refreshedClips.find((clip) => clip.asset_id === assetId);
if (!createdClip) {
throw new Error("The new walk up clip could not be found after saving the media.");
}
return createdClip;
};
const createSourceMutation = useMutation({
mutationFn: async () => {
if (sourceMode === "upload") {
if (!file) {
throw new Error("Select a media file first");
}
setSourceProgress({
label: "Uploading file",
detail: "Sending the media file to the server.",
percent: 0,
});
const asset = await api.uploadAsset({
teamId,
playerId,
title: sourceTitle.trim() || file.name,
file,
onUploadProgress: (percent) => {
setSourceProgress({
label: "Uploading file",
detail: "Sending the media file to the server.",
percent,
});
},
onProcessingStart: () => {
setSourceProgress({
label: "Generating waveform",
detail: "Upload complete. Normalizing audio and building the waveform preview.",
percent: null,
});
},
});
const clip = await resolveCreatedClip(asset.id);
return clip;
}
if (sourceMode === "url") {
if (!importUrl.trim()) {
throw new Error("Paste a media URL first");
}
setSourceProgress({
label: "Importing media",
detail: "Downloading the media, normalizing audio, and generating the waveform.",
percent: null,
});
const asset = await api.importAssetFromUrl({
external_team_id: teamId,
owner_external_player_id: playerId,
url: importUrl.trim(),
title: sourceTitle.trim() || undefined,
});
const clip = await resolveCreatedClip(asset.id);
return clip;
}
if (!existingAssetId) {
throw new Error("Choose an existing media file first");
}
setSourceProgress({
label: "Creating clip",
detail: "Creating a walkup clip from the selected media.",
percent: null,
});
const clip = await api.createClip({
asset_id: Number(existingAssetId),
external_team_id: teamId,
owner_external_player_id: playerId,
label: draftLabel.trim() || "Walkup clip",
start_ms: 0,
end_ms: DEFAULT_CLIP_LENGTH_MS,
});
await queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] });
return clip;
},
onSuccess: (clip) => {
setSourceProgress(null);
setDraftClip(clip);
setDraftStartMs(clip.start_ms);
setDraftEndMs(clip.end_ms);
setDraftLabel(clip.label || "Walkup clip");
setStep("editor");
},
onError: () => {
setSourceProgress(null);
},
});
function resetToSource() {
stopPreview();
setSourceProgress(null);
setStep("source");
setDraftClip(null);
setFileInputKey((current) => current + 1);
}
function handleClose() {
stopPreview();
onClose();
}
function handleSourceSubmit(event: FormEvent) {
event.preventDefault();
void createSourceMutation.mutateAsync();
}
return (
<div
className="walkup-modal-backdrop"
role="presentation"
onClick={handleClose}
>
<section
className="walkup-modal card shadow-lg border-0 w-100"
role="dialog"
aria-modal="true"
aria-labelledby="walkup-clip-create-title"
onClick={(event) => event.stopPropagation()}
>
<div className="walkup-modal-header">
<div>
<p className="eyebrow mb-1">{isCreateMode ? "Create walk up clip" : "Edit walk up clip"}</p>
<h2 id="walkup-clip-create-title" className="h3 mb-0">
{step === "source" ? "Choose a source" : isCreateMode ? "Trim and name the clip" : "Edit metadata"}
</h2>
</div>
<button
type="button"
className="btn btn-outline-secondary btn-sm icon-button"
onClick={handleClose}
aria-label="Close modal"
title="Close modal"
>
<BootstrapIcon name="x-lg" />
</button>
</div>
<div className="walkup-modal-body">
<div className="walkup-stepper">
<div className={`walkup-step${step === "source" ? " is-active" : " is-complete"}`}>1. Source</div>
<div className={`walkup-step${step === "editor" ? " is-active" : ""}`}>2. Trim and metadata</div>
</div>
{step === "source" ? (
<form className="stack" onSubmit={handleSourceSubmit} aria-busy={createSourceMutation.isPending}>
<label className="field">
Walkup clip name
<input
value={draftLabel}
onChange={(event) => setDraftLabel(event.target.value)}
placeholder="Optional clip name"
autoComplete="off"
disabled={createSourceMutation.isPending}
/>
</label>
<ul className="nav nav-tabs" role="tablist" aria-label="Walkup clip source">
<li className="nav-item" role="presentation">
<button
id="walkup-upload-tab"
type="button"
role="tab"
className={`nav-link${sourceMode === "upload" ? " active" : ""}`}
aria-selected={sourceMode === "upload"}
aria-controls="walkup-upload-panel"
disabled={createSourceMutation.isPending}
onClick={() => setSourceMode("upload")}
>
Upload file
</button>
</li>
<li className="nav-item" role="presentation">
<button
id="walkup-url-tab"
type="button"
role="tab"
className={`nav-link${sourceMode === "url" ? " active" : ""}`}
aria-selected={sourceMode === "url"}
aria-controls="walkup-url-panel"
disabled={createSourceMutation.isPending}
onClick={() => setSourceMode("url")}
>
Import URL
</button>
</li>
<li className="nav-item" role="presentation">
<button
id="walkup-existing-tab"
type="button"
role="tab"
className={`nav-link${sourceMode === "existing" ? " active" : ""}`}
aria-selected={sourceMode === "existing"}
aria-controls="walkup-existing-panel"
disabled={createSourceMutation.isPending}
onClick={() => setSourceMode("existing")}
>
Existing media
</button>
</li>
</ul>
<div className="tab-content pt-2">
<div
id="walkup-upload-panel"
role="tabpanel"
aria-labelledby="walkup-upload-tab"
className={`tab-pane fade${sourceMode === "upload" ? " show active" : ""}`}
>
<div className="stack">
<label className="field">
Media title
<input
value={sourceTitle}
onChange={(event) => setSourceTitle(event.target.value)}
placeholder="Song or media title"
disabled={createSourceMutation.isPending}
/>
</label>
<label className="field">
Media file
<input
key={fileInputKey}
type="file"
accept={MEDIA_ACCEPT}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
disabled={createSourceMutation.isPending}
/>
</label>
<div className="muted">Upload a file and the backend will create a walk up clip from it.</div>
</div>
</div>
<div
id="walkup-url-panel"
role="tabpanel"
aria-labelledby="walkup-url-tab"
className={`tab-pane fade${sourceMode === "url" ? " show active" : ""}`}
>
<div className="stack">
<label className="field">
Media title
<input
value={sourceTitle}
onChange={(event) => setSourceTitle(event.target.value)}
placeholder="Optional media title"
disabled={createSourceMutation.isPending}
/>
</label>
<label className="field">
Media URL
<input
value={importUrl}
onChange={(event) => setImportUrl(event.target.value)}
placeholder="https://..."
autoCapitalize="none"
autoComplete="off"
spellCheck={false}
disabled={createSourceMutation.isPending}
/>
</label>
<div className="muted">Import a URL and the backend will download and normalize it for the clip flow.</div>
</div>
</div>
<div
id="walkup-existing-panel"
role="tabpanel"
aria-labelledby="walkup-existing-tab"
className={`tab-pane fade${sourceMode === "existing" ? " show active" : ""}`}
>
<div className="stack">
<label className="field">
Existing media file
<select
value={existingAssetId}
onChange={(event) => setExistingAssetId(event.target.value)}
disabled={createSourceMutation.isPending}
>
<option value="">Choose a file</option>
{assets.map((asset) => (
<option key={asset.id} value={String(asset.id)}>
{asset.title || asset.original_filename}
</option>
))}
</select>
</label>
{existingAssetId ? (
<div className="panel-note">The clip will be created from the selected existing media file.</div>
) : (
<div className="muted">Choose one of the existing media files in this walkup media library.</div>
)}
</div>
</div>
</div>
{sourceProgress ? <SourceProgressPanel progress={sourceProgress} /> : null}
<div className="row walkup-modal-actions">
<button type="submit" className="btn btn-primary" disabled={createSourceMutation.isPending}>
{createSourceMutation.isPending ? sourceProgress?.label ?? "Creating..." : "Create walk up clip"}
</button>
<button type="button" className="btn btn-outline-secondary" onClick={handleClose}>
Cancel
</button>
</div>
{createSourceMutation.error instanceof Error ? (
<div className="muted">{createSourceMutation.error.message}</div>
) : null}
</form>
) : null}
{step === "editor" && draftClip ? (
<WalkupClipEditorPanel
clip={draftClip}
label={draftLabel}
setLabel={setDraftLabel}
startMs={draftStartMs}
setStartMs={setDraftStartMs}
endMs={draftEndMs}
setEndMs={setDraftEndMs}
onPreview={(startMs, endMs) => void playPreview(draftClip, startMs, endMs)}
onStopPreview={stopPreview}
previewTimeMs={previewTimeMs}
playerId={playerId}
onSaveComplete={async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
]);
handleClose();
}}
saveButtonLabel={isCreateMode ? "Save walk up clip" : "Save changes"}
introText={
isCreateMode
? `Source: ${draftClip.asset_title}. Use the controls below to trim the clip and update its metadata.`
: `Edit ${draftClip.asset_title}. Update the clip range and metadata below.`
}
onChangeSource={isCreateMode ? resetToSource : undefined}
closeLabel={isCreateMode ? "Cancel" : "Close"}
onClose={handleClose}
onDelete={async () => onDeleteClip(draftClip.id)}
isDeleting={isDeletingClip}
/>
) : null}
</div>
</section>
</div>
);
}
function ManageUploadedMediaModal({
assets,
teamId,
playerId,
onClose,
stopPreview,
}: {
assets: AudioAsset[];
teamId: string;
playerId: string;
onClose: () => void;
stopPreview: () => void;
}) {
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
stopPreview();
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, stopPreview]);
const deleteAssetMutation = useMutation({
mutationFn: (assetId: number) => api.deleteAsset(assetId, playerId),
onSuccess: async () => {
stopPreview();
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
]);
},
});
function handleClose() {
stopPreview();
onClose();
}
return (
<div className="walkup-modal-backdrop" role="presentation" onClick={handleClose}>
<section
className="walkup-modal card shadow-lg border-0 w-100"
role="dialog"
aria-modal="true"
aria-labelledby="walkup-media-title"
onClick={(event) => event.stopPropagation()}
>
<div className="walkup-modal-header">
<div>
<p className="eyebrow mb-1">Walkup clips</p>
<h2 id="walkup-media-title" className="h3 mb-0">
Manage uploaded media
</h2>
</div>
<button
type="button"
className="btn btn-outline-secondary btn-sm icon-button"
onClick={handleClose}
aria-label="Close modal"
title="Close modal"
>
<BootstrapIcon name="x-lg" />
</button>
</div>
<div className="walkup-modal-body">
<div className="panel-note">
Rename or delete the uploaded media that backs your walkup clips. Existing clip edits stay in the clip view.
</div>
<div className="stack">
{assets.map((asset) => (
<UploadedMediaCard
key={asset.id}
asset={asset}
onDelete={() => void deleteAssetMutation.mutateAsync(asset.id)}
isDeleting={deleteAssetMutation.isPending}
teamId={teamId}
playerId={playerId}
/>
))}
{!assets.length ? <div className="muted">No uploaded media has been added yet.</div> : null}
{deleteAssetMutation.error instanceof Error ? <div className="muted">{deleteAssetMutation.error.message}</div> : null}
</div>
</div>
</section>
</div>
);
}
function WalkupClipCard({
clip,
isPreviewing,
onPreview,
onEdit,
onStopPreview,
}: {
clip: AudioClip;
isPreviewing: boolean;
onPreview: () => void;
onEdit: () => void;
onStopPreview: () => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
return (
<div className="clip-summary">
<div className="clip-summary-header">
<button
type="button"
className={`btn btn-sm icon-button icon-button-circle${isPreviewing ? " btn-success" : " btn-outline-secondary"}`}
onClick={isPreviewing ? onStopPreview : onPreview}
disabled={!clip.normalized_url}
aria-label={isPreviewing ? "Stop preview" : "Preview clip"}
title={isPreviewing ? "Stop preview" : "Preview clip"}
>
<BootstrapIcon name={isPreviewing ? "stop" : "play"} />
</button>
<div className="clip-summary-title-row">
<strong>{clip.label}</strong>
</div>
<div className="clip-summary-menu-wrap">
<button
type="button"
className="icon-button-bare icon-button-menu"
onClick={() => setMenuOpen((current) => !current)}
aria-label="Clip menu"
aria-expanded={menuOpen}
aria-haspopup="menu"
title="Clip menu"
>
<BootstrapIcon name="three-dots-vertical" />
</button>
{menuOpen ? (
<div className="clip-summary-menu" role="menu">
<button type="button" className="clip-summary-menu-item" role="menuitem" onClick={onEdit}>
<span className="clip-summary-menu-icon">
<BootstrapIcon name="pencil-square" />
</span>
<span>Edit clip</span>
</button>
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
</div>
) : null}
</div>
</div>
</div>
);
}
function SourceProgressPanel({ progress }: { progress: SourceCreationProgress }) {
const progressValue = progress.percent ?? 100;
return (
<div className="source-progress-panel" role="status" aria-live="polite">
<div className="source-progress-header">
<strong>{progress.label}</strong>
{progress.percent !== null ? <span>{progress.percent}%</span> : <span>Working...</span>}
</div>
<div className={`source-progress-bar${progress.percent === null ? " is-indeterminate" : ""}`}>
<div className="source-progress-bar-fill" style={{ width: `${progressValue}%` }} />
</div>
<div className="muted">{progress.detail}</div>
</div>
);
}
function WalkupClipEditorPanel({
clip,
label,
setLabel,
startMs,
setStartMs,
endMs,
setEndMs,
previewTimeMs,
onPreview,
onStopPreview,
onSaveComplete,
saveButtonLabel,
introText,
onChangeSource,
closeLabel,
onClose,
onDelete,
isDeleting,
playerId,
}: {
clip: AudioClip;
label: string;
setLabel: (value: string) => void;
startMs: number;
setStartMs: (value: number) => void;
endMs: number;
setEndMs: (value: number) => void;
previewTimeMs: number | null;
onPreview: (startMs: number, endMs: number) => void;
onStopPreview: () => void;
onSaveComplete: () => Promise<void>;
saveButtonLabel: string;
introText: string;
onChangeSource?: () => void;
closeLabel: string;
onClose: () => void;
onDelete: () => Promise<void>;
isDeleting: boolean;
playerId: string;
}) {
useEffect(() => {
setLabel(clip.label ?? "");
setStartMs(clip.start_ms);
setEndMs(clip.end_ms);
}, [clip.end_ms, clip.id, clip.label, clip.start_ms, setEndMs, setLabel, setStartMs]);
const previewClip = {
...clip,
start_ms: startMs,
end_ms: endMs,
label,
};
const saveMutation = useMutation({
mutationFn: async () => {
const trimmedLabel = label.trim();
if (!trimmedLabel) {
throw new Error("Walkup clip name cannot be blank");
}
if (endMs <= startMs) {
throw new Error("Clip end must be greater than start");
}
return api.updateClip(clip.id, {
label: trimmedLabel !== clip.label ? trimmedLabel : undefined,
start_ms: startMs,
end_ms: endMs,
}, playerId);
},
});
async function handleSave() {
try {
await saveMutation.mutateAsync();
await onSaveComplete();
} catch {
// Mutation state already captures the error for display in the modal.
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
await handleSave();
}
function useThirtySecondLength() {
setEndMs(startMs + DEFAULT_CLIP_LENGTH_MS);
}
function handleStartChange(nextStart: number) {
setStartMs(nextStart);
if (nextStart >= endMs) {
setEndMs(nextStart + DEFAULT_CLIP_LENGTH_MS);
}
}
function handleEndChange(nextEnd: number) {
setEndMs(nextEnd);
if (nextEnd <= startMs) {
setStartMs(Math.max(0, nextEnd - DEFAULT_CLIP_LENGTH_MS));
}
}
const trimmedLabel = label.trim();
const canSave = trimmedLabel.length > 0 && endMs > startMs && !saveMutation.isPending;
return (
<form className="stack" onSubmit={handleSubmit}>
<div className="panel-note">{introText}</div>
<label className="field">
Walkup clip name
<input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="Walkup clip name"
autoComplete="off"
/>
</label>
<ClipTrimScrubber
clip={clip}
durationMs={clip.waveform_duration_ms ?? Math.max(endMs, startMs + DEFAULT_CLIP_LENGTH_MS, DEFAULT_CLIP_LENGTH_MS)}
waveformPeaks={clip.waveform_peaks ?? []}
startMs={startMs}
endMs={endMs}
previewTimeMs={previewTimeMs}
onStartChange={handleStartChange}
onEndChange={handleEndChange}
onPreview={() => void onPreview(startMs, endMs)}
onStopPreview={onStopPreview}
/>
{previewTimeMs !== null ? (
<div className="panel-note">
Preview range: {formatClipRange(previewClip.start_ms, previewClip.end_ms)}
{" "}
Current time: {formatPlaybackPosition(previewTimeMs)} / {formatPlaybackPosition(previewClip.end_ms)}
</div>
) : null}
<div className="row walkup-modal-actions">
<button type="button" className="btn btn-primary" onClick={() => void handleSave()} disabled={!canSave}>
{saveMutation.isPending ? "Saving..." : saveButtonLabel}
</button>
<button
type="button"
className="btn btn-outline-danger"
onClick={() => void onDelete()}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</button>
{onChangeSource ? (
<button type="button" className="btn btn-outline-secondary" onClick={onChangeSource}>
Change source
</button>
) : null}
<button type="button" className="btn btn-outline-secondary" onClick={onClose}>
{closeLabel}
</button>
</div>
{saveMutation.error instanceof Error ? <div className="muted">{saveMutation.error.message}</div> : null}
</form>
);
}
function snapTrimMs(value: number, stepMs: number): number {
return Math.round(value / stepMs) * stepMs;
}
function peaksForWaveSurfer(peaks: number[]): number[][] | undefined {
if (!peaks.length) {
return undefined;
}
return [peaks.map((peak) => Math.max(0, Math.min(1, peak / 100)))];
}
function ClipTrimScrubber({
clip,
durationMs,
waveformPeaks,
startMs,
endMs,
previewTimeMs,
onStartChange,
onEndChange,
onPreview,
onStopPreview,
}: {
clip: AudioClip;
durationMs: number;
waveformPeaks: number[];
startMs: number;
endMs: number;
previewTimeMs: number | null;
onStartChange: (value: number) => void;
onEndChange: (value: number) => void;
onPreview: () => void;
onStopPreview: () => void;
}) {
const waveformContainerRef = useRef<HTMLDivElement | null>(null);
const waveSurferRef = useRef<WaveSurfer | null>(null);
const regionsPluginRef = useRef<RegionsPlugin | null>(null);
const trimRegionRef = useRef<Region | null>(null);
const suppressRegionUpdateRef = useRef(false);
const latestTrimRef = useRef({
onStartChange,
onEndChange,
stepMs: TRIM_STEP_MS,
scrubMaxMs: durationMs,
});
const scrubMaxMs = Math.max(durationMs, endMs, startMs + 1);
const safeStartMs = Math.min(startMs, scrubMaxMs - 1);
const safeEndMs = Math.max(endMs, safeStartMs + 1);
const [zoomAmount, setZoomAmount] = useState(55);
const [focusEdge, setFocusEdge] = useState<TrimFocusEdge>("start");
const [waveformReady, setWaveformReady] = useState(false);
const zoomRatio = Math.pow(Math.max(0, Math.min(1, zoomAmount / TRIM_ZOOM_SLIDER_MAX)), 1.8);
const closeWindowMs = Math.min(scrubMaxMs, TRIM_ZOOM_WINDOW_MS);
const targetViewDurationMs = zoomAmount <= 0 ? scrubMaxMs : scrubMaxMs - (scrubMaxMs - closeWindowMs) * zoomRatio;
const viewCenterMs = focusEdge === "end" ? safeEndMs : safeStartMs;
const halfWindowMs = targetViewDurationMs / 2;
let viewStartMs = zoomAmount <= 0 ? 0 : Math.max(0, viewCenterMs - halfWindowMs);
let viewEndMs = zoomAmount <= 0 ? scrubMaxMs : Math.min(scrubMaxMs, viewCenterMs + halfWindowMs);
if (zoomAmount > 0 && viewEndMs - viewStartMs < targetViewDurationMs) {
if (viewStartMs === 0) {
viewEndMs = Math.min(scrubMaxMs, targetViewDurationMs);
} else {
viewStartMs = Math.max(0, scrubMaxMs - targetViewDurationMs);
}
}
const viewDurationMs = Math.max(1, viewEndMs - viewStartMs);
const sliderStepMs = TRIM_STEP_MS;
useEffect(() => {
latestTrimRef.current = {
onStartChange,
onEndChange,
stepMs: sliderStepMs,
scrubMaxMs,
};
}, [onEndChange, onStartChange, scrubMaxMs, sliderStepMs]);
useEffect(() => {
const container = waveformContainerRef.current;
if (!container || !clip.normalized_url) {
return;
}
setWaveformReady(false);
trimRegionRef.current = null;
const regions = RegionsPlugin.create();
const wavesurfer = WaveSurfer.create({
container,
url: `${API_BASE}${clip.normalized_url}`,
peaks: peaksForWaveSurfer(waveformPeaks),
duration: scrubMaxMs / 1000,
height: 110,
waveColor: "rgba(19, 34, 56, 0.28)",
progressColor: "rgba(217, 79, 4, 0.8)",
cursorColor: "rgba(19, 34, 56, 0.9)",
cursorWidth: 2,
minPxPerSec: 1,
fillParent: true,
normalize: true,
dragToSeek: false,
interact: false,
hideScrollbar: false,
plugins: [regions],
});
waveSurferRef.current = wavesurfer;
regionsPluginRef.current = regions;
const unsubscribeReady = wavesurfer.on("ready", () => {
const region = regions.addRegion({
id: "walkup-trim-region",
start: safeStartMs / 1000,
end: safeEndMs / 1000,
color: "rgba(217, 79, 4, 0.18)",
drag: false,
resize: true,
resizeStart: true,
resizeEnd: true,
minLength: 0.05,
});
trimRegionRef.current = region;
setWaveformReady(true);
});
const unsubscribeRegionUpdate = regions.on("region-update", (region, side) => {
if (region.id !== "walkup-trim-region" || suppressRegionUpdateRef.current) {
return;
}
const { onStartChange: updateStart, onEndChange: updateEnd, stepMs, scrubMaxMs: maxMs } = latestTrimRef.current;
const nextStartMs = Math.max(0, Math.min(maxMs - 1, snapTrimMs(region.start * 1000, stepMs)));
const nextEndMs = Math.max(nextStartMs + 1, Math.min(maxMs, snapTrimMs(region.end * 1000, stepMs)));
if (side !== "end") {
setFocusEdge("start");
updateStart(nextStartMs);
}
if (side !== "start") {
setFocusEdge("end");
updateEnd(nextEndMs);
}
});
return () => {
unsubscribeRegionUpdate();
unsubscribeReady();
trimRegionRef.current = null;
regionsPluginRef.current = null;
waveSurferRef.current = null;
setWaveformReady(false);
wavesurfer.destroy();
};
}, [clip.id, clip.normalized_url, durationMs, waveformPeaks]);
useEffect(() => {
const region = trimRegionRef.current;
if (!region) {
return;
}
suppressRegionUpdateRef.current = true;
region.setOptions({
start: safeStartMs / 1000,
end: safeEndMs / 1000,
});
window.setTimeout(() => {
suppressRegionUpdateRef.current = false;
}, 0);
}, [safeEndMs, safeStartMs]);
useEffect(() => {
const wavesurfer = waveSurferRef.current;
const container = waveformContainerRef.current;
if (!wavesurfer || !container || !waveformReady) {
return;
}
const viewDurationSeconds = Math.max(0.1, viewDurationMs / 1000);
const minPxPerSec = zoomAmount <= 0 ? 1 : Math.max(1, container.clientWidth / viewDurationSeconds);
wavesurfer.zoom(minPxPerSec);
wavesurfer.setScrollTime(zoomAmount <= 0 ? 0 : viewStartMs / 1000);
}, [viewDurationMs, viewStartMs, waveformReady, zoomAmount]);
useEffect(() => {
const wavesurfer = waveSurferRef.current;
if (!wavesurfer || previewTimeMs === null) {
return;
}
wavesurfer.setTime(previewTimeMs / 1000);
}, [previewTimeMs]);
function handleZoomAmountChange(value: number) {
setZoomAmount(Math.max(0, Math.min(TRIM_ZOOM_SLIDER_MAX, value)));
}
function nudgeStart(deltaMs: number) {
const nextStartMs = Math.max(0, safeStartMs + deltaMs);
setFocusEdge("start");
onStartChange(nextStartMs);
}
function nudgeEnd(deltaMs: number) {
const nextEndMs = Math.max(safeStartMs + 1, safeEndMs + deltaMs);
setFocusEdge("end");
onEndChange(nextEndMs);
}
return (
<div className="clip-waveform-shell">
<div className="clip-waveform-header">
<div>
<strong>Trim waveform</strong>
<div className="muted">
{formatClipRange(safeStartMs, safeEndMs)} | Step {sliderStepMs}ms
</div>
</div>
<div className="clip-waveform-meta">
<span className="pill">Source: {formatPlaybackPosition(durationMs)}</span>
</div>
</div>
<div className="clip-wavesurfer" ref={waveformContainerRef} aria-label="Trim waveform region" />
<label className="clip-zoom-scrubber">
<span className="clip-zoom-scrubber-header">
<span>Zoom scrubber</span>
<span className="muted">{zoomAmount === 0 ? "Full file" : `${zoomAmount}%`}</span>
</span>
<input
type="range"
min={0}
max={TRIM_ZOOM_SLIDER_MAX}
step={1}
value={zoomAmount}
onChange={(event) => handleZoomAmountChange(Number(event.target.value))}
disabled={!clip.normalized_url || !waveformReady}
/>
</label>
<div className="clip-waveform-controls">
<div className="clip-waveform-control">
<div className="clip-waveform-control-header">
<span>Start</span>
<span className="muted">{formatPlaybackPosition(safeStartMs)}</span>
<div className="clip-waveform-nudges">
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => nudgeStart(-TRIM_NUDGE_MS)}>
-{TRIM_NUDGE_MS}ms
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => nudgeStart(TRIM_NUDGE_MS)}>
+{TRIM_NUDGE_MS}ms
</button>
</div>
</div>
</div>
<div className="clip-waveform-control">
<div className="clip-waveform-control-header">
<span>End</span>
<span className="muted">{formatPlaybackPosition(safeEndMs)}</span>
<div className="clip-waveform-nudges">
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => nudgeEnd(-TRIM_NUDGE_MS)}>
-{TRIM_NUDGE_MS}ms
</button>
<button type="button" className="btn btn-outline-secondary btn-sm" onClick={() => nudgeEnd(TRIM_NUDGE_MS)}>
+{TRIM_NUDGE_MS}ms
</button>
</div>
</div>
</div>
</div>
<div className="clip-waveform-preview-action">
<button
type="button"
className={`btn clip-waveform-preview-button${previewTimeMs !== null ? " btn-danger" : " btn-outline-secondary"}`}
onClick={previewTimeMs !== null ? onStopPreview : onPreview}
disabled={!clip.normalized_url}
aria-label={previewTimeMs !== null ? "Stop preview" : "Preview clip"}
title={previewTimeMs !== null ? "Stop preview" : "Preview clip"}
>
<BootstrapIcon name={previewTimeMs !== null ? "stop" : "play"} />
<span>{previewTimeMs !== null ? "Stop preview" : "Preview clip"}</span>
</button>
</div>
</div>
);
}
function UploadedMediaCard({
asset,
onDelete,
isDeleting,
teamId,
playerId,
}: {
asset: AudioAsset;
onDelete: () => void;
isDeleting: boolean;
teamId: string;
playerId: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [title, setTitle] = useState(asset.title ?? "");
useEffect(() => {
setIsEditing(false);
setTitle(asset.title ?? "");
}, [asset.id, asset.title]);
const updateMutation = useMutation({
mutationFn: () => api.updateAsset(asset.id, { title: title.trim() }, playerId),
onSuccess: async () => {
setIsEditing(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["assets", teamId, playerId] }),
queryClient.invalidateQueries({ queryKey: ["clips", teamId, playerId] }),
]);
},
});
function handleSubmit(event: FormEvent) {
event.preventDefault();
void updateMutation.mutateAsync();
}
const trimmedTitle = title.trim();
const titleChanged = trimmedTitle !== asset.title;
const canSave = isEditing && trimmedTitle.length > 0 && titleChanged && !updateMutation.isPending;
return (
<form className="asset-card" onSubmit={handleSubmit}>
<div className="asset-card-header">
<div className="asset-card-copy">
<strong>{isEditing ? "Editing media title" : asset.title || "Untitled media"}</strong>
<div className="muted asset-card-filename">Uploaded as: {asset.original_filename || "Unknown file"}</div>
</div>
<div className="row asset-card-actions">
<span className="pill">{Math.round(asset.size_bytes / 1024)} KB</span>
<button
type="button"
className="btn btn-outline-secondary btn-sm"
onClick={() => setIsEditing((current) => !current)}
aria-pressed={isEditing}
>
{isEditing ? "Done" : "Edit title"}
</button>
<button type="button" className="btn btn-outline-danger btn-sm" onClick={onDelete} disabled={isDeleting}>
Delete
</button>
</div>
</div>
{isEditing ? (
<label className="field">
Media title
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Media title"
autoComplete="off"
/>
</label>
) : null}
<div className="asset-card-meta">
{isEditing && trimmedTitle.length === 0 ? <div className="muted">Media title cannot be blank.</div> : null}
{updateMutation.error instanceof Error ? <div className="muted">{updateMutation.error.message}</div> : null}
{!isEditing ? <div className="muted">Open Edit title to rename this uploaded media item.</div> : null}
</div>
{isEditing ? (
<div className="row asset-card-footer">
<button type="submit" className="btn btn-primary btn-sm" disabled={!canSave}>
{updateMutation.isPending ? "Saving..." : "Save title"}
</button>
</div>
) : null}
</form>
);
}