1463 lines
49 KiB
TypeScript
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>
|
|
);
|
|
}
|