Share playback state across views

This commit is contained in:
Codex
2026-04-23 11:13:34 -05:00
parent 226a50ea10
commit 0c24ba7111
2 changed files with 41 additions and 60 deletions

View File

@@ -7,17 +7,25 @@ type PlayClipArgs = {
url?: string | null; url?: string | null;
startMs: number; startMs: number;
endMs: number; endMs: number;
title?: string;
subtitle?: string;
};
export type ClipPlaybackDetails = {
key: string;
title?: string;
subtitle?: string;
}; };
export function useClipPlayback() { export function useClipPlayback() {
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const mediaSourceRef = useRef<MediaElementAudioSourceNode | null>(null);
const gainNodeRef = useRef<GainNode | null>(null); const gainNodeRef = useRef<GainNode | null>(null);
const playbackRangeRef = useRef<{ endSeconds: number } | null>(null); const playbackRangeRef = useRef<{ endSeconds: number } | null>(null);
const fadeOutTimerRef = useRef<number | null>(null); const fadeOutTimerRef = useRef<number | null>(null);
const activeKeyRef = useRef<string | null>(null); const activeKeyRef = useRef<string | null>(null);
const [activeKey, setActiveKey] = useState<string | null>(null); const [activeKey, setActiveKey] = useState<string | null>(null);
const [activeDetails, setActiveDetails] = useState<ClipPlaybackDetails | null>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTimeMs, setCurrentTimeMs] = useState<number | null>(null); const [currentTimeMs, setCurrentTimeMs] = useState<number | null>(null);
@@ -56,7 +64,6 @@ export function useClipPlayback() {
source.connect(gain); source.connect(gain);
gain.connect(context.destination); gain.connect(context.destination);
audioContextRef.current = context; audioContextRef.current = context;
mediaSourceRef.current = source;
gainNodeRef.current = gain; gainNodeRef.current = gain;
} }
} }
@@ -100,6 +107,7 @@ export function useClipPlayback() {
activeKeyRef.current = null; activeKeyRef.current = null;
playbackRangeRef.current = null; playbackRangeRef.current = null;
setActiveKey(null); setActiveKey(null);
setActiveDetails(null);
setIsPlaying(false); setIsPlaying(false);
setCurrentTimeMs(null); setCurrentTimeMs(null);
} }
@@ -131,7 +139,7 @@ export function useClipPlayback() {
}, safeDuration); }, safeDuration);
} }
async function playClip({ key, url, startMs, endMs }: PlayClipArgs) { async function playClip({ key, url, startMs, endMs, title, subtitle }: PlayClipArgs) {
if (!url) { if (!url) {
return; return;
} }
@@ -146,6 +154,11 @@ export function useClipPlayback() {
const nextAudio = ensureAudio(); const nextAudio = ensureAudio();
activeKeyRef.current = key; activeKeyRef.current = key;
setActiveKey(key); setActiveKey(key);
setActiveDetails({
key,
title,
subtitle,
});
setIsPlaying(false); setIsPlaying(false);
nextAudio.pause(); nextAudio.pause();
setPlaybackGain(1); setPlaybackGain(1);
@@ -183,6 +196,7 @@ export function useClipPlayback() {
return { return {
activeKey, activeKey,
activeDetails,
currentTimeMs, currentTimeMs,
fadeOutClip, fadeOutClip,
isPlaying, isPlaying,

View File

@@ -23,12 +23,6 @@ function clipKey(kind: "assignment" | "library", id: number | string): string {
return `${kind}:${id}`; return `${kind}:${id}`;
} }
type NowPlaying = {
key: string;
title: string;
subtitle: string;
};
const DEFAULT_FADE_OUT_MS = 1000; const DEFAULT_FADE_OUT_MS = 1000;
function getAvailabilityIconClass(statusCode: number | null | undefined): string { function getAvailabilityIconClass(statusCode: number | null | undefined): string {
@@ -65,12 +59,18 @@ 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 [nowPlaying, setNowPlaying] = useState<NowPlaying | null>(null);
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 teamId = walkup.selectedTeamId; const teamId = walkup.selectedTeamId;
const { activeKey: playingClipKey, fadeOutClip, isPlaying: isPlaybackPlaying, playClip: playClipPreview, stopClip } = useClipPlayback(); const {
activeKey: playingClipKey,
activeDetails: nowPlaying,
fadeOutClip,
isPlaying: isPlaybackPlaying,
playClip: playClipPreview,
stopClip,
} = useClipPlayback();
useEffect(() => { useEffect(() => {
const requestedGameId = searchParams.get("gameId"); const requestedGameId = searchParams.get("gameId");
@@ -84,14 +84,14 @@ export function GamedayPage() {
}, [searchParams, selectedGameId, walkup.nextGame]); }, [searchParams, selectedGameId, walkup.nextGame]);
useEffect(() => { useEffect(() => {
stopPlayback(); stopClip();
setExpandedPlayerId(""); setExpandedPlayerId("");
hasInitializedExpandedPlayerRef.current = false; hasInitializedExpandedPlayerRef.current = false;
}, [selectedGameId]); }, [selectedGameId, stopClip]);
useEffect(() => { useEffect(() => {
stopPlayback(); stopClip();
}, [selectedPlayerId]); }, [selectedPlayerId, stopClip]);
useEffect(() => { useEffect(() => {
if (!playerFilterMenuOpen) { if (!playerFilterMenuOpen) {
@@ -214,56 +214,23 @@ export function GamedayPage() {
setSelectedGameId(gameId); setSelectedGameId(gameId);
setSelectedPlayerId(""); setSelectedPlayerId("");
setExpandedPlayerId(""); setExpandedPlayerId("");
stopPlayback();
setSearchParams({ gameId });
}
function stopPlayback() {
stopClip(); stopClip();
setNowPlaying(null); setSearchParams({ gameId });
} }
function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) { function fadeOutPlayback(durationMs = DEFAULT_FADE_OUT_MS) {
fadeOutClip(durationMs); fadeOutClip(durationMs);
} }
async function playAudio(
url: string | null | undefined,
key: string,
playingItem: NowPlaying,
startMs: number,
endMs: number,
) {
if (!url) {
return;
}
if (playingClipKey === key && isPlaybackPlaying) {
stopPlayback();
return;
}
setNowPlaying(playingItem);
try {
await playClipPreview({ key, url, startMs, endMs });
} catch (error) {
stopPlayback();
throw error;
}
}
async function playClip(clip: AudioClip) { async function playClip(clip: AudioClip) {
await playAudio( await playClipPreview({
clip.normalized_url, key: clipKey("library", clip.id),
clipKey("library", clip.id), url: clip.normalized_url,
{ startMs: clip.start_ms,
key: clipKey("library", clip.id), endMs: clip.end_ms,
title: clip.label, title: clip.label,
subtitle: formatMemberName(selectedPlayer), subtitle: formatMemberName(selectedPlayer),
}, });
clip.start_ms,
clip.end_ms,
);
} }
if (!walkup.isTeamSnap) { if (!walkup.isTeamSnap) {
@@ -280,11 +247,11 @@ export function GamedayPage() {
<div className="gameday-toolbar"> <div className="gameday-toolbar">
<div className="gameday-toolbar-copy"> <div className="gameday-toolbar-copy">
<span className="gameday-toolbar-label">Now Playing</span> <span className="gameday-toolbar-label">Now Playing</span>
<strong>{nowPlaying.title}</strong> <strong>{nowPlaying.title ?? "Clip"}</strong>
<span className="muted">{nowPlaying.subtitle}</span> {nowPlaying.subtitle ? <span className="muted">{nowPlaying.subtitle}</span> : null}
</div> </div>
<div className="gameday-toolbar-actions"> <div className="gameday-toolbar-actions">
<button type="button" className="btn btn-danger btn-sm" onClick={() => stopPlayback()}> <button type="button" className="btn btn-danger btn-sm" onClick={() => stopClip()}>
Stop Stop
</button> </button>
<button <button