Unify clip row playback

This commit is contained in:
Codex
2026-04-23 11:05:14 -05:00
parent 74de6f0d0f
commit c355b3ae26
6 changed files with 390 additions and 359 deletions

View File

@@ -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;

View File

@@ -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 (
<div className="clip-summary">
<div className="clip-summary-header">
<button
type="button"
className={`btn btn-sm icon-button icon-button-circle${isPlaying ? " btn-success" : " btn-outline-secondary"}`}
onClick={onTogglePlayback}
disabled={!isPlaybackAvailable}
aria-label={isPlaying ? "Stop playback" : "Play clip"}
title={isPlaying ? "Stop playback" : "Play clip"}
>
<i className={`bi ${isPlaying ? "bi-stop-fill" : "bi-play-fill"}`} aria-hidden="true" />
</button>
<div className="clip-summary-title-row">
<strong>{clip.label}</strong>
{titleExtras}
</div>
{actions ? <div className="clip-summary-actions">{actions}</div> : null}
</div>
{subtitle ? <div className="clip-summary-subtitle">{subtitle}</div> : null}
</div>
);
}

View File

@@ -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<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const playbackRangeRef = useRef<{ endSeconds: number } | null>(null);
const fadeOutTimerRef = useRef<number | null>(null);
const activeKeyRef = useRef<string | null>(null);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTimeMs, setCurrentTimeMs] = useState<number | null>(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<void>((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,
};
}

View File

@@ -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<string | null>(null);
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
const selectedPlayerWasManualRef = useRef(false);
const playerFilterMenuRef = useRef<HTMLDivElement | null>(null);
const hasInitializedExpandedPlayerRef = useRef(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null);
const playbackRangeRef = useRef<{ startSeconds: number; endSeconds: number } | null>(null);
const fadeOutTimerRef = useRef<number | null>(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<unknown>,
) {
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<void>((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 (
<>
<div className="stack">
{clips.map((clip) => {
const key = clipKey("library", clip.id);
const isPlaying = playingClipKey === key;
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
return (
<div className="list-group-item list-group-item-action d-flex align-items-center gap-3" key={clip.id}>
<div className="flex-grow-1 d-grid gap-1 min-w-0">
<strong className="text-truncate">
{clip.label}
{isPinned ? <span className="ms-2 badge rounded-pill text-bg-success">Pinned</span> : null}
</strong>
{isPinned ? <div className="text-body-secondary small">Pinned to this game</div> : null}
</div>
<button
type="button"
className={`btn btn-sm ${isPlaying ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => void onPlayClip(clip)}
aria-pressed={isPlaying}
>
{isPlaying ? "Stop" : "Play"}
</button>
</div>
<ClipSummaryRow
key={clip.id}
clip={clip}
isPlaying={isPlaying}
onTogglePlayback={() => void onPlayClip(clip)}
titleExtras={isPinned ? <span className="badge rounded-pill text-bg-success">Pinned</span> : null}
subtitle={isPinned ? <span className="text-body-secondary small">Pinned to this game</span> : null}
/>
);
})}
</>
</div>
);
}

View File

@@ -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<WalkupClipModalState | null>(null);
const [clipPinModalClipId, setClipPinModalClipId] = useState<number | 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 {
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<void>((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() {
<WalkupClipCard
key={clip.id}
clip={clip}
isPreviewing={previewClipId === clip.id}
onPreview={() => 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,22 +951,14 @@ function WalkupClipCard({
}, [menuOpen]);
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>
{isHidden ? <span className="pill">Hidden</span> : null}
</div>
<ClipSummaryRow
clip={clip}
isPlaying={isPlaying}
onTogglePlayback={onTogglePlayback}
isPlaybackAvailable={Boolean(clip.normalized_url)}
titleExtras={isHidden ? <span className="pill">Hidden</span> : null}
actions={
<>
<div className="clip-summary-order-controls">
<button
type="button"
@@ -1089,7 +996,9 @@ function WalkupClipCard({
</button>
{menuOpen ? (
<div ref={menuPanelRef} className={`clip-summary-menu${menuDirection === "up" ? " is-up" : ""}`} role="menu">
<div className="clip-summary-menu-label">Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}</div>
<div className="clip-summary-menu-label">
Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}
</div>
<button
type="button"
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
@@ -1136,8 +1045,9 @@ function WalkupClipCard({
</div>
) : null}
</div>
</div>
</div>
</>
}
/>
);
}

View File

@@ -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;