diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 1784d12..0427bdf 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -10,7 +10,7 @@ import type {
TeamSnapTokenResponse,
} from "./types";
-const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
+export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
type UploadAssetPayload = {
teamId: string;
diff --git a/frontend/src/components/ClipSummaryRow.tsx b/frontend/src/components/ClipSummaryRow.tsx
new file mode 100644
index 0000000..b00e2d5
--- /dev/null
+++ b/frontend/src/components/ClipSummaryRow.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react";
+
+import type { AudioClip } from "../api/types";
+
+export function ClipSummaryRow({
+ clip,
+ isPlaying,
+ onTogglePlayback,
+ titleExtras,
+ subtitle,
+ actions,
+ isPlaybackAvailable = true,
+}: {
+ clip: AudioClip;
+ isPlaying: boolean;
+ onTogglePlayback: () => void;
+ titleExtras?: ReactNode;
+ subtitle?: ReactNode;
+ actions?: ReactNode;
+ isPlaybackAvailable?: boolean;
+}) {
+ return (
+
+
+
+
+ {clip.label}
+ {titleExtras}
+
+ {actions ?
{actions}
: null}
+
+ {subtitle ?
{subtitle}
: null}
+
+ );
+}
diff --git a/frontend/src/hooks/useClipPlayback.ts b/frontend/src/hooks/useClipPlayback.ts
new file mode 100644
index 0000000..f8bea7e
--- /dev/null
+++ b/frontend/src/hooks/useClipPlayback.ts
@@ -0,0 +1,192 @@
+import { useEffect, useRef, useState } from "react";
+
+import { API_BASE } from "../api/client";
+
+type PlayClipArgs = {
+ key: string;
+ url?: string | null;
+ startMs: number;
+ endMs: number;
+};
+
+export function useClipPlayback() {
+ const audioRef = useRef(null);
+ const audioContextRef = useRef(null);
+ const mediaSourceRef = useRef(null);
+ const gainNodeRef = useRef(null);
+ const playbackRangeRef = useRef<{ endSeconds: number } | null>(null);
+ const fadeOutTimerRef = useRef(null);
+ const activeKeyRef = useRef(null);
+ const [activeKey, setActiveKey] = useState(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [currentTimeMs, setCurrentTimeMs] = useState(null);
+
+ useEffect(() => stopClip(), []);
+
+ function ensureAudio() {
+ const audio = audioRef.current ?? new Audio();
+ if (!audioRef.current) {
+ audio.onplay = () => {
+ setIsPlaying(true);
+ };
+ audio.onpause = () => {
+ setIsPlaying(false);
+ };
+ audio.onended = () => {
+ stopClip();
+ };
+ audio.ontimeupdate = () => {
+ const range = playbackRangeRef.current;
+ if (!range) {
+ return;
+ }
+ setCurrentTimeMs(Math.round(audio.currentTime * 1000));
+ if (audio.currentTime >= range.endSeconds) {
+ stopClip();
+ }
+ };
+
+ const AudioContextCtor =
+ window.AudioContext ?? (window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ if (AudioContextCtor && !audioContextRef.current) {
+ const context = new AudioContextCtor();
+ const source = context.createMediaElementSource(audio);
+ const gain = context.createGain();
+ gain.gain.value = 1;
+ source.connect(gain);
+ gain.connect(context.destination);
+ audioContextRef.current = context;
+ mediaSourceRef.current = source;
+ gainNodeRef.current = gain;
+ }
+ }
+
+ audioRef.current = audio;
+ return audio;
+ }
+
+ function setPlaybackGain(value: number) {
+ const gainNode = gainNodeRef.current;
+ if (!gainNode) {
+ return;
+ }
+
+ gainNode.gain.cancelScheduledValues(gainNode.context.currentTime);
+ gainNode.gain.setValueAtTime(value, gainNode.context.currentTime);
+ }
+
+ function clearFadeOutTimer() {
+ if (fadeOutTimerRef.current !== null) {
+ window.clearTimeout(fadeOutTimerRef.current);
+ fadeOutTimerRef.current = null;
+ }
+ }
+
+ function stopClip(resetGain = true) {
+ clearFadeOutTimer();
+
+ const audio = audioRef.current;
+ if (audio) {
+ audio.pause();
+ audio.currentTime = 0;
+ audio.removeAttribute("src");
+ audio.load();
+ }
+
+ if (resetGain) {
+ setPlaybackGain(1);
+ }
+
+ activeKeyRef.current = null;
+ playbackRangeRef.current = null;
+ setActiveKey(null);
+ setIsPlaying(false);
+ setCurrentTimeMs(null);
+ }
+
+ function fadeOutClip(durationMs = 1000) {
+ const audio = audioRef.current;
+ if (!audio || audio.paused) {
+ stopClip();
+ return;
+ }
+
+ clearFadeOutTimer();
+ const gainNode = gainNodeRef.current;
+ if (!gainNode) {
+ stopClip();
+ return;
+ }
+
+ const safeDuration = Math.max(1, durationMs);
+ const now = gainNode.context.currentTime;
+ const currentGain = gainNode.gain.value;
+
+ gainNode.gain.cancelScheduledValues(now);
+ gainNode.gain.setValueAtTime(currentGain, now);
+ gainNode.gain.linearRampToValueAtTime(0, now + safeDuration / 1000);
+
+ fadeOutTimerRef.current = window.setTimeout(() => {
+ stopClip(false);
+ }, safeDuration);
+ }
+
+ async function playClip({ key, url, startMs, endMs }: PlayClipArgs) {
+ if (!url) {
+ return;
+ }
+
+ const audio = ensureAudio();
+ if (activeKeyRef.current === key && !audio.paused) {
+ stopClip();
+ return;
+ }
+
+ stopClip();
+ const nextAudio = ensureAudio();
+ activeKeyRef.current = key;
+ setActiveKey(key);
+ setIsPlaying(false);
+ nextAudio.pause();
+ setPlaybackGain(1);
+
+ const audioContext = audioContextRef.current;
+ if (audioContext?.state === "suspended") {
+ await audioContext.resume();
+ }
+
+ const startSeconds = startMs / 1000;
+ const endSeconds = endMs / 1000;
+ playbackRangeRef.current = { endSeconds };
+ setCurrentTimeMs(startMs);
+
+ const metadataReady = new Promise((resolve) => {
+ nextAudio.onloadedmetadata = () => {
+ if (activeKeyRef.current === key) {
+ nextAudio.currentTime = startSeconds;
+ setCurrentTimeMs(startMs);
+ }
+ resolve();
+ };
+ });
+
+ nextAudio.src = `${API_BASE}${url}`;
+ await metadataReady;
+
+ try {
+ await nextAudio.play();
+ } catch (error) {
+ stopClip();
+ throw error;
+ }
+ }
+
+ return {
+ activeKey,
+ currentTimeMs,
+ fadeOutClip,
+ isPlaying,
+ playClip,
+ stopClip,
+ };
+}
diff --git a/frontend/src/pages/GamedayPage.tsx b/frontend/src/pages/GamedayPage.tsx
index d0a4b12..2b1f648 100644
--- a/frontend/src/pages/GamedayPage.tsx
+++ b/frontend/src/pages/GamedayPage.tsx
@@ -4,6 +4,8 @@ import { useSearchParams } from "react-router-dom";
import { api } from "../api/client";
import type { AudioClip, GameAssignment } from "../api/types";
+import { ClipSummaryRow } from "../components/ClipSummaryRow";
+import { useClipPlayback } from "../hooks/useClipPlayback";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { loadPreparedGame } from "../lib/offlinePrep";
import { teamsnapClient } from "../lib/teamsnap";
@@ -63,19 +65,12 @@ export function GamedayPage() {
const [expandedPlayerId, setExpandedPlayerId] = useState("");
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false);
- const [playingClipKey, setPlayingClipKey] = useState(null);
const [nowPlaying, setNowPlaying] = useState(null);
- const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
const selectedPlayerWasManualRef = useRef(false);
const playerFilterMenuRef = useRef(null);
const hasInitializedExpandedPlayerRef = useRef(false);
- const audioRef = useRef(null);
- const audioContextRef = useRef(null);
- const mediaSourceRef = useRef(null);
- const gainNodeRef = useRef(null);
- const playbackRangeRef = useRef<{ startSeconds: number; endSeconds: number } | null>(null);
- const fadeOutTimerRef = useRef(null);
const teamId = walkup.selectedTeamId;
+ const { activeKey: playingClipKey, fadeOutClip, isPlaying: isPlaybackPlaying, playClip: playClipPreview, stopClip } = useClipPlayback();
useEffect(() => {
const requestedGameId = searchParams.get("gameId");
@@ -219,105 +214,17 @@ export function GamedayPage() {
setSelectedGameId(gameId);
setSelectedPlayerId("");
setExpandedPlayerId("");
- setPlayingClipKey(null);
- setNowPlaying(null);
+ stopPlayback();
setSearchParams({ gameId });
}
- function getAudio() {
- const audio = audioRef.current ?? new Audio();
- if (!audioRef.current) {
- audio.onplay = () => {
- setIsPlaybackPlaying(true);
- };
- audio.onpause = () => {
- setIsPlaybackPlaying(false);
- };
- audio.onended = () => {
- stopPlayback();
- };
- audio.ontimeupdate = () => {
- const range = playbackRangeRef.current;
- if (!range) {
- return;
- }
- if (audio.currentTime >= range.endSeconds) {
- stopPlayback();
- }
- };
- const AudioContextCtor = window.AudioContext ?? (window as Window & typeof globalThis & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
- if (AudioContextCtor && !audioContextRef.current) {
- const context = new AudioContextCtor();
- const source = context.createMediaElementSource(audio);
- const gain = context.createGain();
- gain.gain.value = 1;
- source.connect(gain);
- gain.connect(context.destination);
- audioContextRef.current = context;
- mediaSourceRef.current = source;
- gainNodeRef.current = gain;
- }
- }
- audioRef.current = audio;
- return audio;
- }
-
- function setPlaybackGain(value: number) {
- const gainNode = gainNodeRef.current;
- if (gainNode) {
- gainNode.gain.cancelScheduledValues(gainNode.context.currentTime);
- gainNode.gain.setValueAtTime(value, gainNode.context.currentTime);
- }
- }
-
- function clearFadeOutTimer() {
- if (fadeOutTimerRef.current !== null) {
- window.clearTimeout(fadeOutTimerRef.current);
- fadeOutTimerRef.current = null;
- }
- }
-
- function stopPlayback(resetGain = true) {
- clearFadeOutTimer();
- const audio = audioRef.current;
- if (audio) {
- audio.pause();
- audio.currentTime = 0;
- }
- if (resetGain) {
- setPlaybackGain(1);
- }
- playbackRangeRef.current = null;
- setPlayingClipKey(null);
+ function stopPlayback() {
+ stopClip();
setNowPlaying(null);
- setIsPlaybackPlaying(false);
}
function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) {
- const audio = audioRef.current;
- if (!audio || audio.paused) {
- stopPlayback();
- return;
- }
-
- clearFadeOutTimer();
- const gainNode = gainNodeRef.current;
- if (!gainNode) {
- stopPlayback();
- return;
- }
-
- const safeDuration = Math.max(1, durationMs);
- const now = gainNode.context.currentTime;
- const currentGain = gainNode.gain.value;
-
- gainNode.gain.cancelScheduledValues(now);
- gainNode.gain.setValueAtTime(currentGain, now);
- gainNode.gain.linearRampToValueAtTime(0, now + safeDuration / 1000);
-
- fadeOutTimerRef.current = window.setTimeout(() => {
- stopPlayback(false);
- }, safeDuration);
+ fadeOutClip(durationMs);
}
async function playAudio(
@@ -326,49 +233,21 @@ export function GamedayPage() {
playingItem: NowPlaying,
startMs: number,
endMs: number,
- onPlay?: () => Promise,
) {
if (!url) {
return;
}
- const audio = getAudio();
- if (playingClipKey === key && !audio.paused) {
+ if (playingClipKey === key && isPlaybackPlaying) {
stopPlayback();
return;
}
- setPlayingClipKey(key);
setNowPlaying(playingItem);
- setIsPlaybackPlaying(false);
- audio.pause();
- setPlaybackGain(1);
- if (audioContextRef.current?.state === "suspended") {
- await audioContextRef.current.resume();
- }
- const startSeconds = startMs / 1000;
- const endSeconds = endMs / 1000;
- playbackRangeRef.current = { startSeconds, endSeconds };
- const metadataReady = new Promise((resolve) => {
- audio.onloadedmetadata = () => {
- if (playbackRangeRef.current?.endSeconds === endSeconds) {
- audio.currentTime = startSeconds;
- }
- resolve();
- };
- });
- audio.src = `${import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"}${url}`;
- await metadataReady;
try {
- await audio.play();
- if (onPlay) {
- await onPlay();
- }
+ await playClipPreview({ key, url, startMs, endMs });
} catch (error) {
- if (audio.paused) {
- setPlayingClipKey(null);
- setNowPlaying(null);
- }
+ stopPlayback();
throw error;
}
}
@@ -655,31 +534,22 @@ function LibraryClips({
});
return (
- <>
+
{clips.map((clip) => {
const key = clipKey("library", clip.id);
const isPlaying = playingClipKey === key;
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
return (
-
-
-
- {clip.label}
- {isPinned ? Pinned : null}
-
- {isPinned ?
Pinned to this game
: null}
-
-
-
+
void onPlayClip(clip)}
+ titleExtras={isPinned ? Pinned : null}
+ subtitle={isPinned ? Pinned to this game : null}
+ />
);
})}
- >
+
);
}
diff --git a/frontend/src/pages/LibraryPage.tsx b/frontend/src/pages/LibraryPage.tsx
index 524e41d..750dc64 100644
--- a/frontend/src/pages/LibraryPage.tsx
+++ b/frontend/src/pages/LibraryPage.tsx
@@ -3,8 +3,10 @@ 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 { API_BASE, api } from "../api/client";
import type { AudioAsset, AudioClip, TeamSnapEvent } from "../api/types";
+import { ClipSummaryRow } from "../components/ClipSummaryRow";
+import { useClipPlayback } from "../hooks/useClipPlayback";
import { useWalkupContext } from "../hooks/useWalkupContext";
import { queryClient } from "../lib/queryClient";
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
@@ -12,7 +14,6 @@ import { formatGameTitle, 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;
@@ -59,11 +60,12 @@ export function LibraryPage() {
const [walkupClipModal, setWalkupClipModal] = useState(null);
const [clipPinModalClipId, setClipPinModalClipId] = 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 {
+ activeKey: previewKey,
+ currentTimeMs: previewTimeMs,
+ playClip: playClipPreview,
+ stopClip: stopPreview,
+ } = useClipPlayback();
const assetsQuery = useQuery({
queryKey: ["assets", teamId, playerId],
@@ -101,12 +103,6 @@ export function LibraryPage() {
return counts;
}, [pinsQuery.data]);
- useEffect(() => {
- return () => {
- stopPreview();
- };
- }, []);
-
const deleteClipMutation = useMutation({
mutationFn: (clipId: number) => api.deleteClip(clipId, playerId),
onSuccess: async () => {
@@ -176,91 +172,13 @@ export function LibraryPage() {
},
});
- 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();
- };
+ async function playPreview(clip: AudioClip, startMsOverride?: number, endMsOverride?: number) {
+ await playClipPreview({
+ key: String(clip.id),
+ url: clip.normalized_url,
+ startMs: startMsOverride ?? clip.start_ms,
+ endMs: endMsOverride ?? clip.end_ms,
});
- nextAudio.src = `${API_BASE}${clip.normalized_url}`;
- await metadataReady;
-
- try {
- await nextAudio.play();
- } catch (error) {
- stopPreview();
- throw error;
- }
}
function openCreateWalkupClip() {
@@ -320,10 +238,9 @@ export function LibraryPage() {
void playPreview(clip)}
+ isPlaying={previewKey === String(clip.id)}
+ onTogglePlayback={() => void playPreview(clip)}
onEdit={() => openEditWalkupClip(clip)}
- onStopPreview={stopPreview}
onMoveUp={() => {
const nextOrder = [...orderedClips];
if (index <= 0) {
@@ -963,10 +880,9 @@ function ManageUploadedMediaModal({
function WalkupClipCard({
clip,
- isPreviewing,
- onPreview,
+ isPlaying,
+ onTogglePlayback,
onEdit,
- onStopPreview,
onMoveUp,
onMoveDown,
canMoveUp,
@@ -977,10 +893,9 @@ function WalkupClipCard({
isHidden,
}: {
clip: AudioClip;
- isPreviewing: boolean;
- onPreview: () => void;
+ isPlaying: boolean;
+ onTogglePlayback: () => void;
onEdit: () => void;
- onStopPreview: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
canMoveUp: boolean;
@@ -1036,108 +951,103 @@ function WalkupClipCard({
}, [menuOpen]);
return (
-
-
-
-
- {clip.label}
- {isHidden ? Hidden : null}
-
-
-
-
-
-
-
- {menuOpen ? (
-
-
Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}
-
-
-
-
Source: {clip.asset_title}
-
- ) : null}
-
-
-
+ Hidden : null}
+ actions={
+ <>
+
+
+
+
+
+
+ {menuOpen ? (
+
+
+ Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}
+
+
+
+
+
Source: {clip.asset_title}
+
+ ) : null}
+
+ >
+ }
+ />
);
}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 31b1d4c..b71752c 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -309,6 +309,21 @@ select {
overflow-wrap: anywhere;
}
+.clip-summary-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex: 0 0 auto;
+}
+
+.clip-summary-subtitle {
+ margin-left: 2.35rem;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
.clip-summary-order-controls {
display: inline-flex;
align-items: center;