Unify clip row playback
This commit is contained in:
@@ -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;
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,108 +951,103 @@ 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>
|
||||
<div className="clip-summary-order-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
aria-label="Move clip up"
|
||||
title="Move clip up"
|
||||
>
|
||||
<BootstrapIcon name="chevron-up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
aria-label="Move clip down"
|
||||
title="Move clip down"
|
||||
>
|
||||
<BootstrapIcon name="chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="clip-summary-menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center px-2"
|
||||
ref={menuButtonRef}
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
aria-label="Clip menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
title="Clip menu"
|
||||
>
|
||||
<BootstrapIcon name="three-dots" />
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpenPinModal();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pin-fill" />
|
||||
</span>
|
||||
<span>Pin to game</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onToggleHidden();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name={isHidden ? "eye" : "eye-slash"} />
|
||||
</span>
|
||||
<span>{isHidden ? "Show in gameday" : "Hide from gameday"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pencil-square" />
|
||||
</span>
|
||||
<span>Edit clip</span>
|
||||
</button>
|
||||
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
aria-label="Move clip up"
|
||||
title="Move clip up"
|
||||
>
|
||||
<BootstrapIcon name="chevron-up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm icon-button icon-button-circle btn-outline-secondary"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
aria-label="Move clip down"
|
||||
title="Move clip down"
|
||||
>
|
||||
<BootstrapIcon name="chevron-down" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="clip-summary-menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary d-inline-flex align-items-center justify-content-center px-2"
|
||||
ref={menuButtonRef}
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
aria-label="Clip menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
title="Clip menu"
|
||||
>
|
||||
<BootstrapIcon name="three-dots" />
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onOpenPinModal();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pin-fill" />
|
||||
</span>
|
||||
<span>Pin to game</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onToggleHidden();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name={isHidden ? "eye" : "eye-slash"} />
|
||||
</span>
|
||||
<span>{isHidden ? "Show in gameday" : "Hide from gameday"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline-secondary clip-summary-menu-action"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<span className="clip-summary-menu-icon">
|
||||
<BootstrapIcon name="pencil-square" />
|
||||
</span>
|
||||
<span>Edit clip</span>
|
||||
</button>
|
||||
<div className="clip-summary-menu-label">Source: {clip.asset_title}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user