Share playback state across views
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
clipKey("library", clip.id),
|
|
||||||
{
|
|
||||||
key: clipKey("library", clip.id),
|
key: clipKey("library", clip.id),
|
||||||
|
url: clip.normalized_url,
|
||||||
|
startMs: clip.start_ms,
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user