Unify clip row playback
This commit is contained in:
@@ -10,7 +10,7 @@ import type {
|
|||||||
TeamSnapTokenResponse,
|
TeamSnapTokenResponse,
|
||||||
} from "./types";
|
} 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 = {
|
type UploadAssetPayload = {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|||||||
44
frontend/src/components/ClipSummaryRow.tsx
Normal file
44
frontend/src/components/ClipSummaryRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/hooks/useClipPlayback.ts
Normal file
192
frontend/src/hooks/useClipPlayback.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { useSearchParams } from "react-router-dom";
|
|||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { AudioClip, GameAssignment } from "../api/types";
|
import type { AudioClip, GameAssignment } from "../api/types";
|
||||||
|
import { ClipSummaryRow } from "../components/ClipSummaryRow";
|
||||||
|
import { useClipPlayback } from "../hooks/useClipPlayback";
|
||||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||||
import { loadPreparedGame } from "../lib/offlinePrep";
|
import { loadPreparedGame } from "../lib/offlinePrep";
|
||||||
import { teamsnapClient } from "../lib/teamsnap";
|
import { teamsnapClient } from "../lib/teamsnap";
|
||||||
@@ -63,19 +65,12 @@ export function GamedayPage() {
|
|||||||
const [expandedPlayerId, setExpandedPlayerId] = useState("");
|
const [expandedPlayerId, setExpandedPlayerId] = useState("");
|
||||||
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
|
const [playerFilter, setPlayerFilter] = useState<"players" | "nonPlayers" | "all">("players");
|
||||||
const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false);
|
const [playerFilterMenuOpen, setPlayerFilterMenuOpen] = useState(false);
|
||||||
const [playingClipKey, setPlayingClipKey] = useState<string | null>(null);
|
|
||||||
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
|
const [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
|
||||||
const [isPlaybackPlaying, setIsPlaybackPlaying] = useState(false);
|
|
||||||
const selectedPlayerWasManualRef = useRef(false);
|
const selectedPlayerWasManualRef = useRef(false);
|
||||||
const playerFilterMenuRef = useRef<HTMLDivElement | null>(null);
|
const playerFilterMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hasInitializedExpandedPlayerRef = useRef(false);
|
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 teamId = walkup.selectedTeamId;
|
||||||
|
const { activeKey: playingClipKey, fadeOutClip, isPlaying: isPlaybackPlaying, playClip: playClipPreview, stopClip } = useClipPlayback();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestedGameId = searchParams.get("gameId");
|
const requestedGameId = searchParams.get("gameId");
|
||||||
@@ -219,105 +214,17 @@ export function GamedayPage() {
|
|||||||
setSelectedGameId(gameId);
|
setSelectedGameId(gameId);
|
||||||
setSelectedPlayerId("");
|
setSelectedPlayerId("");
|
||||||
setExpandedPlayerId("");
|
setExpandedPlayerId("");
|
||||||
setPlayingClipKey(null);
|
stopPlayback();
|
||||||
setNowPlaying(null);
|
|
||||||
setSearchParams({ gameId });
|
setSearchParams({ gameId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAudio() {
|
function stopPlayback() {
|
||||||
const audio = audioRef.current ?? new Audio();
|
stopClip();
|
||||||
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);
|
|
||||||
setNowPlaying(null);
|
setNowPlaying(null);
|
||||||
setIsPlaybackPlaying(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) {
|
function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) {
|
||||||
const audio = audioRef.current;
|
fadeOutClip(durationMs);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playAudio(
|
async function playAudio(
|
||||||
@@ -326,49 +233,21 @@ export function GamedayPage() {
|
|||||||
playingItem: NowPlaying,
|
playingItem: NowPlaying,
|
||||||
startMs: number,
|
startMs: number,
|
||||||
endMs: number,
|
endMs: number,
|
||||||
onPlay?: () => Promise<unknown>,
|
|
||||||
) {
|
) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const audio = getAudio();
|
if (playingClipKey === key && isPlaybackPlaying) {
|
||||||
if (playingClipKey === key && !audio.paused) {
|
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setPlayingClipKey(key);
|
|
||||||
setNowPlaying(playingItem);
|
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 {
|
try {
|
||||||
await audio.play();
|
await playClipPreview({ key, url, startMs, endMs });
|
||||||
if (onPlay) {
|
|
||||||
await onPlay();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (audio.paused) {
|
stopPlayback();
|
||||||
setPlayingClipKey(null);
|
|
||||||
setNowPlaying(null);
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,31 +534,22 @@ function LibraryClips({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="stack">
|
||||||
{clips.map((clip) => {
|
{clips.map((clip) => {
|
||||||
const key = clipKey("library", clip.id);
|
const key = clipKey("library", clip.id);
|
||||||
const isPlaying = playingClipKey === key;
|
const isPlaying = playingClipKey === key;
|
||||||
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
|
const isPinned = pinnedAssignmentsByClipId.has(String(clip.id));
|
||||||
return (
|
return (
|
||||||
<div className="list-group-item list-group-item-action d-flex align-items-center gap-3" key={clip.id}>
|
<ClipSummaryRow
|
||||||
<div className="flex-grow-1 d-grid gap-1 min-w-0">
|
key={clip.id}
|
||||||
<strong className="text-truncate">
|
clip={clip}
|
||||||
{clip.label}
|
isPlaying={isPlaying}
|
||||||
{isPinned ? <span className="ms-2 badge rounded-pill text-bg-success">Pinned</span> : null}
|
onTogglePlayback={() => void onPlayClip(clip)}
|
||||||
</strong>
|
titleExtras={isPinned ? <span className="badge rounded-pill text-bg-success">Pinned</span> : null}
|
||||||
{isPinned ? <div className="text-body-secondary small">Pinned to this game</div> : null}
|
subtitle={isPinned ? <span className="text-body-secondary small">Pinned to this game</span> : 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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import WaveSurfer from "wavesurfer.js";
|
import WaveSurfer from "wavesurfer.js";
|
||||||
import RegionsPlugin, { type Region } from "wavesurfer.js/plugins/regions";
|
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 type { AudioAsset, AudioClip, TeamSnapEvent } from "../api/types";
|
||||||
|
import { ClipSummaryRow } from "../components/ClipSummaryRow";
|
||||||
|
import { useClipPlayback } from "../hooks/useClipPlayback";
|
||||||
import { useWalkupContext } from "../hooks/useWalkupContext";
|
import { useWalkupContext } from "../hooks/useWalkupContext";
|
||||||
import { queryClient } from "../lib/queryClient";
|
import { queryClient } from "../lib/queryClient";
|
||||||
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
|
import { formatClipRange, formatPlaybackPosition } from "../lib/media";
|
||||||
@@ -12,7 +14,6 @@ import { formatGameTitle, formatMemberName } from "../lib/teamsnapHelpers";
|
|||||||
|
|
||||||
const MEDIA_ACCEPT =
|
const MEDIA_ACCEPT =
|
||||||
".mp3,.m4a,.aac,.wav,.ogg,.oga,.flac,.mp4,.m4v,.mov,audio/*,video/*,application/octet-stream";
|
".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 DEFAULT_CLIP_LENGTH_MS = 30_000;
|
||||||
const TRIM_NUDGE_MS = 100;
|
const TRIM_NUDGE_MS = 100;
|
||||||
const TRIM_STEP_MS = 100;
|
const TRIM_STEP_MS = 100;
|
||||||
@@ -59,11 +60,12 @@ export function LibraryPage() {
|
|||||||
const [walkupClipModal, setWalkupClipModal] = useState<WalkupClipModalState | null>(null);
|
const [walkupClipModal, setWalkupClipModal] = useState<WalkupClipModalState | null>(null);
|
||||||
const [clipPinModalClipId, setClipPinModalClipId] = useState<number | null>(null);
|
const [clipPinModalClipId, setClipPinModalClipId] = useState<number | null>(null);
|
||||||
const [manageMediaOpen, setManageMediaOpen] = useState(false);
|
const [manageMediaOpen, setManageMediaOpen] = useState(false);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const {
|
||||||
const previewClipIdRef = useRef<number | null>(null);
|
activeKey: previewKey,
|
||||||
const previewRangeRef = useRef<{ startMs: number; endMs: number } | null>(null);
|
currentTimeMs: previewTimeMs,
|
||||||
const [previewClipId, setPreviewClipId] = useState<number | null>(null);
|
playClip: playClipPreview,
|
||||||
const [previewTimeMs, setPreviewTimeMs] = useState<number | null>(null);
|
stopClip: stopPreview,
|
||||||
|
} = useClipPlayback();
|
||||||
|
|
||||||
const assetsQuery = useQuery({
|
const assetsQuery = useQuery({
|
||||||
queryKey: ["assets", teamId, playerId],
|
queryKey: ["assets", teamId, playerId],
|
||||||
@@ -101,12 +103,6 @@ export function LibraryPage() {
|
|||||||
return counts;
|
return counts;
|
||||||
}, [pinsQuery.data]);
|
}, [pinsQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
stopPreview();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const deleteClipMutation = useMutation({
|
const deleteClipMutation = useMutation({
|
||||||
mutationFn: (clipId: number) => api.deleteClip(clipId, playerId),
|
mutationFn: (clipId: number) => api.deleteClip(clipId, playerId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -176,91 +172,13 @@ export function LibraryPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function getAudio() {
|
async function playPreview(clip: AudioClip, startMsOverride?: number, endMsOverride?: number) {
|
||||||
const audio = audioRef.current ?? new Audio();
|
await playClipPreview({
|
||||||
if (!audioRef.current) {
|
key: String(clip.id),
|
||||||
audio.preload = "auto";
|
url: clip.normalized_url,
|
||||||
audio.onended = () => {
|
startMs: startMsOverride ?? clip.start_ms,
|
||||||
stopPreview();
|
endMs: endMsOverride ?? clip.end_ms,
|
||||||
};
|
|
||||||
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() {
|
function openCreateWalkupClip() {
|
||||||
@@ -320,10 +238,9 @@ export function LibraryPage() {
|
|||||||
<WalkupClipCard
|
<WalkupClipCard
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
clip={clip}
|
clip={clip}
|
||||||
isPreviewing={previewClipId === clip.id}
|
isPlaying={previewKey === String(clip.id)}
|
||||||
onPreview={() => void playPreview(clip)}
|
onTogglePlayback={() => void playPreview(clip)}
|
||||||
onEdit={() => openEditWalkupClip(clip)}
|
onEdit={() => openEditWalkupClip(clip)}
|
||||||
onStopPreview={stopPreview}
|
|
||||||
onMoveUp={() => {
|
onMoveUp={() => {
|
||||||
const nextOrder = [...orderedClips];
|
const nextOrder = [...orderedClips];
|
||||||
if (index <= 0) {
|
if (index <= 0) {
|
||||||
@@ -963,10 +880,9 @@ function ManageUploadedMediaModal({
|
|||||||
|
|
||||||
function WalkupClipCard({
|
function WalkupClipCard({
|
||||||
clip,
|
clip,
|
||||||
isPreviewing,
|
isPlaying,
|
||||||
onPreview,
|
onTogglePlayback,
|
||||||
onEdit,
|
onEdit,
|
||||||
onStopPreview,
|
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
onMoveDown,
|
onMoveDown,
|
||||||
canMoveUp,
|
canMoveUp,
|
||||||
@@ -977,10 +893,9 @@ function WalkupClipCard({
|
|||||||
isHidden,
|
isHidden,
|
||||||
}: {
|
}: {
|
||||||
clip: AudioClip;
|
clip: AudioClip;
|
||||||
isPreviewing: boolean;
|
isPlaying: boolean;
|
||||||
onPreview: () => void;
|
onTogglePlayback: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onStopPreview: () => void;
|
|
||||||
onMoveUp: () => void;
|
onMoveUp: () => void;
|
||||||
onMoveDown: () => void;
|
onMoveDown: () => void;
|
||||||
canMoveUp: boolean;
|
canMoveUp: boolean;
|
||||||
@@ -1036,108 +951,103 @@ function WalkupClipCard({
|
|||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="clip-summary">
|
<ClipSummaryRow
|
||||||
<div className="clip-summary-header">
|
clip={clip}
|
||||||
<button
|
isPlaying={isPlaying}
|
||||||
type="button"
|
onTogglePlayback={onTogglePlayback}
|
||||||
className={`btn btn-sm icon-button icon-button-circle${isPreviewing ? " btn-success" : " btn-outline-secondary"}`}
|
isPlaybackAvailable={Boolean(clip.normalized_url)}
|
||||||
onClick={isPreviewing ? onStopPreview : onPreview}
|
titleExtras={isHidden ? <span className="pill">Hidden</span> : null}
|
||||||
disabled={!clip.normalized_url}
|
actions={
|
||||||
aria-label={isPreviewing ? "Stop preview" : "Preview clip"}
|
<>
|
||||||
title={isPreviewing ? "Stop preview" : "Preview clip"}
|
<div className="clip-summary-order-controls">
|
||||||
>
|
<button
|
||||||
<BootstrapIcon name={isPreviewing ? "stop" : "play"} />
|
type="button"
|
||||||
</button>
|
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||||
<div className="clip-summary-title-row">
|
onClick={onMoveUp}
|
||||||
<strong>{clip.label}</strong>
|
disabled={!canMoveUp}
|
||||||
{isHidden ? <span className="pill">Hidden</span> : null}
|
aria-label="Move clip up"
|
||||||
</div>
|
title="Move clip up"
|
||||||
<div className="clip-summary-order-controls">
|
>
|
||||||
<button
|
<BootstrapIcon name="chevron-up" />
|
||||||
type="button"
|
</button>
|
||||||
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
<button
|
||||||
onClick={onMoveUp}
|
type="button"
|
||||||
disabled={!canMoveUp}
|
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||||
aria-label="Move clip up"
|
onClick={onMoveDown}
|
||||||
title="Move clip up"
|
disabled={!canMoveDown}
|
||||||
>
|
aria-label="Move clip down"
|
||||||
<BootstrapIcon name="chevron-up" />
|
title="Move clip down"
|
||||||
</button>
|
>
|
||||||
<button
|
<BootstrapIcon name="chevron-down" />
|
||||||
type="button"
|
</button>
|
||||||
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
</div>
|
||||||
onClick={onMoveDown}
|
<div className="clip-summary-menu-wrap" ref={menuRef}>
|
||||||
disabled={!canMoveDown}
|
<button
|
||||||
aria-label="Move clip down"
|
type="button"
|
||||||
title="Move clip down"
|
className="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center px-2"
|
||||||
>
|
ref={menuButtonRef}
|
||||||
<BootstrapIcon name="chevron-down" />
|
onClick={() => setMenuOpen((current) => !current)}
|
||||||
</button>
|
aria-label="Clip menu"
|
||||||
</div>
|
aria-expanded={menuOpen}
|
||||||
<div className="clip-summary-menu-wrap" ref={menuRef}>
|
aria-haspopup="menu"
|
||||||
<button
|
title="Clip menu"
|
||||||
type="button"
|
>
|
||||||
className="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center px-2"
|
<BootstrapIcon name="three-dots" />
|
||||||
ref={menuButtonRef}
|
</button>
|
||||||
onClick={() => setMenuOpen((current) => !current)}
|
{menuOpen ? (
|
||||||
aria-label="Clip menu"
|
<div ref={menuPanelRef} className={`clip-summary-menu${menuDirection === "up" ? " is-up" : ""}`} role="menu">
|
||||||
aria-expanded={menuOpen}
|
<div className="clip-summary-menu-label">
|
||||||
aria-haspopup="menu"
|
Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}
|
||||||
title="Clip menu"
|
</div>
|
||||||
>
|
<button
|
||||||
<BootstrapIcon name="three-dots" />
|
type="button"
|
||||||
</button>
|
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||||
{menuOpen ? (
|
role="menuitem"
|
||||||
<div ref={menuPanelRef} className={`clip-summary-menu${menuDirection === "up" ? " is-up" : ""}`} role="menu">
|
onClick={() => {
|
||||||
<div className="clip-summary-menu-label">Pinned to {pinCount} game{pinCount === 1 ? "" : "s"}</div>
|
setMenuOpen(false);
|
||||||
<button
|
onOpenPinModal();
|
||||||
type="button"
|
}}
|
||||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
>
|
||||||
role="menuitem"
|
<span className="clip-summary-menu-icon">
|
||||||
onClick={() => {
|
<BootstrapIcon name="pin-fill" />
|
||||||
setMenuOpen(false);
|
</span>
|
||||||
onOpenPinModal();
|
<span>Pin to game</span>
|
||||||
}}
|
</button>
|
||||||
>
|
<button
|
||||||
<span className="clip-summary-menu-icon">
|
type="button"
|
||||||
<BootstrapIcon name="pin-fill" />
|
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||||
</span>
|
role="menuitem"
|
||||||
<span>Pin to game</span>
|
onClick={() => {
|
||||||
</button>
|
setMenuOpen(false);
|
||||||
<button
|
onToggleHidden();
|
||||||
type="button"
|
}}
|
||||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
>
|
||||||
role="menuitem"
|
<span className="clip-summary-menu-icon">
|
||||||
onClick={() => {
|
<BootstrapIcon name={isHidden ? "eye" : "eye-slash"} />
|
||||||
setMenuOpen(false);
|
</span>
|
||||||
onToggleHidden();
|
<span>{isHidden ? "Show in gameday" : "Hide from gameday"}</span>
|
||||||
}}
|
</button>
|
||||||
>
|
<button
|
||||||
<span className="clip-summary-menu-icon">
|
type="button"
|
||||||
<BootstrapIcon name={isHidden ? "eye" : "eye-slash"} />
|
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||||
</span>
|
role="menuitem"
|
||||||
<span>{isHidden ? "Show in gameday" : "Hide from gameday"}</span>
|
onClick={() => {
|
||||||
</button>
|
setMenuOpen(false);
|
||||||
<button
|
onEdit();
|
||||||
type="button"
|
}}
|
||||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
>
|
||||||
role="menuitem"
|
<span className="clip-summary-menu-icon">
|
||||||
onClick={() => {
|
<BootstrapIcon name="pencil-square" />
|
||||||
setMenuOpen(false);
|
</span>
|
||||||
onEdit();
|
<span>Edit clip</span>
|
||||||
}}
|
</button>
|
||||||
>
|
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
||||||
<span className="clip-summary-menu-icon">
|
</div>
|
||||||
<BootstrapIcon name="pencil-square" />
|
) : null}
|
||||||
</span>
|
</div>
|
||||||
<span>Edit clip</span>
|
</>
|
||||||
</button>
|
}
|
||||||
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
/>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,21 @@ select {
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.clip-summary-order-controls {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user