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(null); const [manageMediaOpen, setManageMediaOpen] = useState(false); const audioRef = useRef(null); const previewClipIdRef = useRef(null); const previewRangeRef = useRef<{ startMs: number; endMs: number } | null>(null); const [previewClipId, setPreviewClipId] = useState(null); const [previewTimeMs, setPreviewTimeMs] = useState(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((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
Reconnect with TeamSnap to manage walkup clips.
; } if (!teamId || !playerId) { return (
No player record was found on the selected team, so this account cannot add walkup clips yet.
); } return (

My Clips

{walkup.currentPlayer ? formatMemberName(walkup.currentPlayer) : "Selected Player"}
{clipsQuery.data?.map((clip) => ( void playPreview(clip)} onEdit={() => openEditWalkupClip(clip)} onStopPreview={stopPreview} /> ))} {!clipsQuery.isLoading && !clipsQuery.data?.length ? (
No walkup clips created yet. Open the modal to make the first one.
) : null} {deleteClipMutation.error instanceof Error ?
{deleteClipMutation.error.message}
: null}

Uploaded media

Review the source files behind your walkup clips. You can rename or delete uploads here.
{walkupClipModal ? ( { stopPreview(); await deleteClipMutation.mutateAsync(clipId); }} isDeletingClip={deleteClipMutation.isPending} /> ) : null} {manageMediaOpen ? ( ) : null}
); } function BootstrapIcon({ name }: { name: BootstrapIconName }) { if (name === "play") { return ( ); } if (name === "stop") { return ( ); } if (name === "three-dots-vertical") { return ( ); } if (name === "plus-lg") { return ( ); } if (name === "x-lg") { return ( ); } return ( ); } 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; onClose: () => void; stopPreview: () => void; onDeleteClip: (clipId: number) => Promise; isDeletingClip: boolean; }) { const isCreateMode = state.mode === "create"; const [step, setStep] = useState<"source" | "editor">(isCreateMode ? "source" : "editor"); const [sourceMode, setSourceMode] = useState("upload"); const [sourceTitle, setSourceTitle] = useState(""); const [draftLabel, setDraftLabel] = useState(state.mode === "edit" ? state.clip.label ?? "" : ""); const [file, setFile] = useState(null); const [fileInputKey, setFileInputKey] = useState(0); const [importUrl, setImportUrl] = useState(""); const [existingAssetId, setExistingAssetId] = useState(""); const [draftClip, setDraftClip] = useState(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(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 (
event.stopPropagation()} >

{isCreateMode ? "Create walk up clip" : "Edit walk up clip"}

{step === "source" ? "Choose a source" : isCreateMode ? "Trim and name the clip" : "Edit metadata"}

1. Source
2. Trim and metadata
{step === "source" ? (
Upload a file and the backend will create a walk up clip from it.
Import a URL and the backend will download and normalize it for the clip flow.
{existingAssetId ? (
The clip will be created from the selected existing media file.
) : (
Choose one of the existing media files in this walkup media library.
)}
{sourceProgress ? : null}
{createSourceMutation.error instanceof Error ? (
{createSourceMutation.error.message}
) : null} ) : null} {step === "editor" && draftClip ? ( 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}
); } 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 (
event.stopPropagation()} >

Walkup clips

Manage uploaded media

Rename or delete the uploaded media that backs your walkup clips. Existing clip edits stay in the clip view.
{assets.map((asset) => ( void deleteAssetMutation.mutateAsync(asset.id)} isDeleting={deleteAssetMutation.isPending} teamId={teamId} playerId={playerId} /> ))} {!assets.length ?
No uploaded media has been added yet.
: null} {deleteAssetMutation.error instanceof Error ?
{deleteAssetMutation.error.message}
: null}
); } function WalkupClipCard({ clip, isPreviewing, onPreview, onEdit, onStopPreview, }: { clip: AudioClip; isPreviewing: boolean; onPreview: () => void; onEdit: () => void; onStopPreview: () => void; }) { const [menuOpen, setMenuOpen] = useState(false); return (
{clip.label}
{menuOpen ? (
Source: {clip.asset_title}
) : null}
); } function SourceProgressPanel({ progress }: { progress: SourceCreationProgress }) { const progressValue = progress.percent ?? 100; return (
{progress.label} {progress.percent !== null ? {progress.percent}% : Working...}
{progress.detail}
); } 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; saveButtonLabel: string; introText: string; onChangeSource?: () => void; closeLabel: string; onClose: () => void; onDelete: () => Promise; 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 (
{introText}
void onPreview(startMs, endMs)} onStopPreview={onStopPreview} /> {previewTimeMs !== null ? (
Preview range: {formatClipRange(previewClip.start_ms, previewClip.end_ms)} {" "} Current time: {formatPlaybackPosition(previewTimeMs)} / {formatPlaybackPosition(previewClip.end_ms)}
) : null}
{onChangeSource ? ( ) : null}
{saveMutation.error instanceof Error ?
{saveMutation.error.message}
: null} ); } 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(null); const waveSurferRef = useRef(null); const regionsPluginRef = useRef(null); const trimRegionRef = useRef(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("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 (
Trim waveform
{formatClipRange(safeStartMs, safeEndMs)} | Step {sliderStepMs}ms
Source: {formatPlaybackPosition(durationMs)}
Start {formatPlaybackPosition(safeStartMs)}
End {formatPlaybackPosition(safeEndMs)}
); } 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 (
{isEditing ? "Editing media title" : asset.title || "Untitled media"}
Uploaded as: {asset.original_filename || "Unknown file"}
{Math.round(asset.size_bytes / 1024)} KB
{isEditing ? ( ) : null}
{isEditing && trimmedTitle.length === 0 ?
Media title cannot be blank.
: null} {updateMutation.error instanceof Error ?
{updateMutation.error.message}
: null} {!isEditing ?
Open Edit title to rename this uploaded media item.
: null}
{isEditing ? (
) : null}
); }