Files
lidify/frontend/lib/audio-controls-context.tsx
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

923 lines
32 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useCallback,
useRef,
useEffect,
ReactNode,
useMemo,
} from "react";
import {
useAudioState,
Track,
Audiobook,
Podcast,
PlayerMode,
} from "./audio-state-context";
import { useAudioPlayback } from "./audio-playback-context";
import { preloadImages } from "@/utils/imageCache";
import { api } from "@/lib/api";
import { audioSeekEmitter } from "./audio-seek-emitter";
function queueDebugEnabled(): boolean {
try {
return (
typeof window !== "undefined" &&
window.localStorage?.getItem("lidifyQueueDebug") === "1"
);
} catch {
return false;
}
}
function queueDebugLog(message: string, data?: Record<string, unknown>) {
if (!queueDebugEnabled()) return;
// eslint-disable-next-line no-console
console.log(`[QueueDebug] ${message}`, data || {});
}
interface AudioControlsContextType {
// Track methods
playTrack: (track: Track) => void;
playTracks: (tracks: Track[], startIndex?: number, isVibeQueue?: boolean) => void;
// Audiobook methods
playAudiobook: (audiobook: Audiobook) => void;
// Podcast methods
playPodcast: (podcast: Podcast) => void;
nextPodcastEpisode: () => void;
// Playback controls
pause: () => void;
resume: () => void;
play: () => void;
next: () => void;
previous: () => void;
// Queue controls
addToQueue: (track: Track) => void;
removeFromQueue: (index: number) => void;
clearQueue: () => void;
setUpcoming: (tracks: Track[], preserveOrder?: boolean) => void; // Replace queue after current track
// Playback modes
toggleShuffle: () => void;
toggleRepeat: () => void;
// Time controls
updateCurrentTime: (time: number) => void;
seek: (time: number) => void;
skipForward: (seconds?: number) => void;
skipBackward: (seconds?: number) => void;
// Player mode controls
setPlayerMode: (mode: PlayerMode) => void;
returnToPreviousMode: () => void;
// Volume controls
setVolume: (volume: number) => void;
toggleMute: () => void;
// Vibe mode controls
startVibeMode: (sourceFeatures: {
bpm?: number | null;
energy?: number | null;
valence?: number | null;
arousal?: number | null;
danceability?: number | null;
keyScale?: string | null;
instrumentalness?: number | null;
analysisMode?: string | null;
// ML Mood predictions
moodHappy?: number | null;
moodSad?: number | null;
moodRelaxed?: number | null;
moodAggressive?: number | null;
moodParty?: number | null;
moodAcoustic?: number | null;
moodElectronic?: number | null;
}, queueIds: string[]) => void;
stopVibeMode: () => void;
}
const AudioControlsContext = createContext<
AudioControlsContextType | undefined
>(undefined);
export function AudioControlsProvider({ children }: { children: ReactNode }) {
const state = useAudioState();
const playback = useAudioPlayback();
const upNextInsertRef = useRef<number>(0);
const shuffleInsertPosRef = useRef<number>(0);
const lastQueueInsertAtRef = useRef<number | null>(null);
const lastCursorTrackIndexRef = useRef<number | null>(null);
const lastCursorIsShuffleRef = useRef<boolean | null>(null);
// Ref to track repeat-one timeout for cleanup
const repeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
queueDebugLog("AudioControlsProvider mounted");
return () => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current);
}
queueDebugLog("AudioControlsProvider unmounted");
};
}, []);
// Keep a stable "Up Next" insertion cursor like Spotify:
// - When the current track changes, reset to "right after current"
// - Each addToQueue inserts at the cursor and advances it
useEffect(() => {
if (state.playbackType !== "track") {
upNextInsertRef.current = 0;
shuffleInsertPosRef.current = 0;
lastCursorTrackIndexRef.current = null;
lastCursorIsShuffleRef.current = null;
return;
}
const prevIdx = lastCursorTrackIndexRef.current;
const prevShuffle = lastCursorIsShuffleRef.current;
const trackChanged = prevIdx !== state.currentIndex;
const shuffleToggled = prevShuffle !== state.isShuffle;
// Up-next cursor should never move backwards unless track changes / shuffle toggles
const baseUpNext = state.currentIndex + 1;
upNextInsertRef.current =
trackChanged || shuffleToggled
? baseUpNext
: Math.max(upNextInsertRef.current, baseUpNext);
if (state.isShuffle) {
const currentShufflePos = state.shuffleIndices.indexOf(
state.currentIndex
);
const baseShufflePos =
currentShufflePos >= 0 ? currentShufflePos + 1 : 0;
// Do NOT reset to base on every shuffleIndices update; only move forward.
shuffleInsertPosRef.current =
trackChanged || shuffleToggled
? baseShufflePos
: Math.max(shuffleInsertPosRef.current, baseShufflePos);
} else {
shuffleInsertPosRef.current = 0;
}
lastCursorTrackIndexRef.current = state.currentIndex;
lastCursorIsShuffleRef.current = state.isShuffle;
queueDebugLog("Cursor updated", {
currentIndex: state.currentIndex,
isShuffle: state.isShuffle,
upNextCursor: upNextInsertRef.current,
shuffleCursor: shuffleInsertPosRef.current,
shuffleIndicesLen: state.shuffleIndices?.length || 0,
queueLen: state.queue?.length || 0,
});
}, [
state.currentIndex,
state.playbackType,
state.isShuffle,
state.shuffleIndices,
state.queue.length,
]);
// Generate shuffled indices
const generateShuffleIndices = useCallback(
(length: number, currentIdx: number) => {
const indices = Array.from({ length }, (_, i) => i).filter(
(i) => i !== currentIdx
);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
return [currentIdx, ...indices];
},
[]
);
const playTrack = useCallback(
(track: Track) => {
// If vibe mode is on and this track isn't in the vibe queue, disable vibe mode
if (state.vibeMode && !state.vibeQueueIds.includes(track.id)) {
state.setVibeMode(false);
state.setVibeSourceFeatures(null);
state.setVibeQueueIds([]);
}
state.setPlaybackType("track");
state.setCurrentTrack(track);
state.setCurrentAudiobook(null);
state.setCurrentPodcast(null);
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing tracks
state.setQueue([track]);
state.setCurrentIndex(0);
playback.setIsPlaying(true);
playback.setCurrentTime(0);
state.setShuffleIndices([0]);
state.setRepeatOneCount(0);
},
[state, playback]
);
const playTracks = useCallback(
(tracks: Track[], startIndex = 0, isVibeQueue = false) => {
if (tracks.length === 0) {
return;
}
queueDebugLog("playTracks()", {
tracksLen: tracks.length,
startIndex,
firstTrackId: tracks[0]?.id,
startTrackId: tracks[startIndex]?.id,
isVibeQueue,
});
// If not a vibe queue and vibe mode is on, disable it
if (!isVibeQueue && state.vibeMode) {
state.setVibeMode(false);
state.setVibeSourceFeatures(null);
state.setVibeQueueIds([]);
}
state.setPlaybackType("track");
state.setCurrentAudiobook(null);
state.setCurrentPodcast(null);
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing tracks
state.setQueue(tracks);
state.setCurrentIndex(startIndex);
state.setCurrentTrack(tracks[startIndex]);
playback.setIsPlaying(true);
playback.setCurrentTime(0);
state.setRepeatOneCount(0);
state.setShuffleIndices(
generateShuffleIndices(tracks.length, startIndex)
);
// Preload cover art
const coverUrls = tracks
.map((t) =>
t.album?.coverArt
? api.getCoverArtUrl(t.album.coverArt, 100)
: null
)
.filter(Boolean) as string[];
preloadImages(coverUrls).catch(() => {});
},
[state, playback, generateShuffleIndices]
);
const playAudiobook = useCallback(
(audiobook: Audiobook) => {
state.setPlaybackType("audiobook");
state.setCurrentAudiobook(audiobook);
state.setCurrentTrack(null);
state.setCurrentPodcast(null);
state.setPodcastEpisodeQueue(null); // Clear podcast queue when playing audiobooks
state.setQueue([]);
state.setCurrentIndex(0);
playback.setIsPlaying(true);
state.setShuffleIndices([]);
if (audiobook.progress?.currentTime) {
playback.setCurrentTime(audiobook.progress.currentTime);
} else {
playback.setCurrentTime(0);
}
},
[state, playback]
);
const playPodcast = useCallback(
(podcast: Podcast) => {
state.setPlaybackType("podcast");
state.setCurrentPodcast(podcast);
state.setCurrentTrack(null);
state.setCurrentAudiobook(null);
state.setQueue([]);
state.setCurrentIndex(0);
playback.setIsPlaying(true);
state.setShuffleIndices([]);
if (podcast.progress?.currentTime) {
playback.setCurrentTime(podcast.progress.currentTime);
} else {
playback.setCurrentTime(0);
}
},
[state, playback]
);
const pause = useCallback(() => {
playback.setIsPlaying(false);
}, [playback]);
const nextPodcastEpisode = useCallback(() => {
if (!state.podcastEpisodeQueue || state.podcastEpisodeQueue.length === 0) {
pause();
return;
}
if (!state.currentPodcast) {
pause();
return;
}
// Extract episodeId from currentPodcast.id (format: "podcastId:episodeId")
const [podcastId, currentEpisodeId] = state.currentPodcast.id.split(":");
// Find current episode index
const currentIndex = state.podcastEpisodeQueue.findIndex(
(ep) => ep.id === currentEpisodeId
);
// If there's a next episode, play it
if (currentIndex >= 0 && currentIndex < state.podcastEpisodeQueue.length - 1) {
const nextEpisode = state.podcastEpisodeQueue[currentIndex + 1];
// Build the podcast object for playback
playPodcast({
id: `${podcastId}:${nextEpisode.id}`,
title: nextEpisode.title,
podcastTitle: state.currentPodcast.podcastTitle,
coverUrl: state.currentPodcast.coverUrl,
duration: nextEpisode.duration,
progress: nextEpisode.progress || null,
});
} else {
// Last episode, pause and clear queue
pause();
state.setPodcastEpisodeQueue(null);
}
}, [state.podcastEpisodeQueue, state.currentPodcast, playPodcast, pause, state.setPodcastEpisodeQueue]);
const resume = useCallback(() => {
playback.setIsPlaying(true);
}, [playback]);
const play = useCallback(() => {
playback.setIsPlaying(true);
}, [playback]);
const next = useCallback(() => {
if (state.queue.length === 0) return;
// Handle repeat one
if (state.repeatMode === "one" && state.repeatOneCount === 0) {
state.setRepeatOneCount(1);
playback.setCurrentTime(0);
playback.setIsPlaying(false);
// Clear any existing timeout before setting a new one
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current);
}
// Short delay for audio element state synchronization
repeatTimeoutRef.current = setTimeout(
() => playback.setIsPlaying(true),
10
);
return;
}
state.setRepeatOneCount(0);
let nextIndex: number;
if (state.isShuffle) {
const currentShufflePos = state.shuffleIndices.indexOf(
state.currentIndex
);
queueDebugLog("next() shuffle", {
currentIndex: state.currentIndex,
currentShufflePos,
shuffleIndicesLen: state.shuffleIndices.length,
});
if (currentShufflePos < state.shuffleIndices.length - 1) {
nextIndex = state.shuffleIndices[currentShufflePos + 1];
} else {
if (state.repeatMode === "all") {
nextIndex = state.shuffleIndices[0];
} else {
return;
}
}
} else {
if (state.currentIndex < state.queue.length - 1) {
nextIndex = state.currentIndex + 1;
} else {
if (state.repeatMode === "all") {
nextIndex = 0;
} else {
return;
}
}
}
queueDebugLog("next() chosen", {
isShuffle: state.isShuffle,
nextIndex,
nextTrackId: state.queue[nextIndex]?.id,
queueLen: state.queue.length,
});
state.setCurrentIndex(nextIndex);
state.setCurrentTrack(state.queue[nextIndex]);
playback.setCurrentTime(0);
playback.setIsPlaying(true);
}, [state, playback]);
const previous = useCallback(() => {
if (state.queue.length === 0) return;
state.setRepeatOneCount(0);
let prevIndex: number;
if (state.isShuffle) {
const currentShufflePos = state.shuffleIndices.indexOf(
state.currentIndex
);
if (currentShufflePos > 0) {
prevIndex = state.shuffleIndices[currentShufflePos - 1];
} else {
return;
}
} else {
if (state.currentIndex > 0) {
prevIndex = state.currentIndex - 1;
} else {
return;
}
}
state.setCurrentIndex(prevIndex);
state.setCurrentTrack(state.queue[prevIndex]);
playback.setCurrentTime(0);
playback.setIsPlaying(true);
}, [state, playback]);
const addToQueue = useCallback(
(track: Track) => {
queueDebugLog("addToQueue() entry", {
trackId: track?.id,
queueLen: state.queue.length,
currentIndex: state.currentIndex,
playbackType: state.playbackType,
isShuffle: state.isShuffle,
upNextCursor: upNextInsertRef.current,
shuffleCursor: shuffleInsertPosRef.current,
});
// If no tracks are playing (empty queue or non-track playback), start fresh
if (state.queue.length === 0 || state.playbackType !== "track") {
state.setPlaybackType("track");
state.setQueue([track]);
state.setCurrentIndex(0);
state.setCurrentTrack(track);
state.setCurrentAudiobook(null);
state.setCurrentPodcast(null);
playback.setIsPlaying(true);
playback.setCurrentTime(0);
state.setShuffleIndices([0]);
queueDebugLog("addToQueue() started fresh queue", {
trackId: track?.id,
});
return;
}
// Spotify-style: "Add to queue" should add to the Up Next list.
// Maintain a cursor so multiple adds preserve order and don't all land in the same slot.
const playingIdx = state.currentIndex;
const plannedInsertAt = upNextInsertRef.current;
state.setQueue((prevQueue) => {
const insertAt = Math.min(
Math.max(0, plannedInsertAt),
prevQueue.length
);
// Keep existing log payload shape: it expects insertAt === currentIdx + 1.
const currentIdx = insertAt - 1;
const newQueue = [...prevQueue];
newQueue.splice(insertAt, 0, track);
upNextInsertRef.current = insertAt + 1;
lastQueueInsertAtRef.current = insertAt;
queueDebugLog("addToQueue() applied", {
plannedInsertAt,
insertAt,
playingIdx,
prevLen: prevQueue.length,
newLen: newQueue.length,
insertedTrackId: track?.id,
nextUpSliceIds: newQueue
.slice(state.currentIndex + 1, state.currentIndex + 6)
.map((t) => t?.id),
});
return newQueue;
});
// Update shuffle indices if shuffle is on - use functional update
if (state.isShuffle) {
state.setShuffleIndices((prevIndices) => {
if (prevIndices.length === 0) return prevIndices;
// Shuffle mode: still support "Up Next" by inserting into the shuffle order
// right after the current shuffle position, preserving add order.
// We cannot perfectly know the queue insertAt here without atomically coupling
// queue+shuffle state; we approximate using the planned insert index and adjust.
const insertAtCandidate =
lastQueueInsertAtRef.current ?? plannedInsertAt;
const insertAt = Math.min(
Math.max(0, insertAtCandidate),
state.queue.length
);
const shifted = prevIndices.map((i) =>
i >= insertAt ? i + 1 : i
);
const currentShufflePos = shifted.indexOf(playingIdx);
const baseInsertPos =
currentShufflePos >= 0 ? currentShufflePos + 1 : 0;
const insertPos = Math.min(
Math.max(baseInsertPos, shuffleInsertPosRef.current),
shifted.length
);
const newIndices = [...shifted];
newIndices.splice(insertPos, 0, insertAt);
shuffleInsertPosRef.current = insertPos + 1;
const newIndex = insertAt;
const currentIdx = playingIdx;
queueDebugLog("addToQueue() shuffleIndices updated", {
currentIdx,
insertAt,
insertPos,
prevIndicesLen: prevIndices.length,
newIndicesLen: newIndices.length,
nextShuffleSlice: newIndices.slice(
Math.max(0, insertPos - 2),
insertPos + 3
),
});
return newIndices;
});
}
},
[state, playback]
);
const removeFromQueue = useCallback(
(index: number) => {
state.setQueue((prev) => {
const newQueue = [...prev];
newQueue.splice(index, 1);
if (index < state.currentIndex) {
state.setCurrentIndex((prevIndex) => prevIndex - 1);
} else if (
index === state.currentIndex &&
index === newQueue.length
) {
state.setCurrentIndex(0);
if (newQueue.length > 0) {
state.setCurrentTrack(newQueue[0]);
} else {
state.setCurrentTrack(null);
playback.setIsPlaying(false);
}
}
return newQueue;
});
},
[state, playback]
);
const clearQueue = useCallback(() => {
state.setQueue([]);
state.setCurrentIndex(0);
state.setCurrentTrack(null);
playback.setIsPlaying(false);
state.setShuffleIndices([]);
}, [state, playback]);
// Set upcoming tracks without interrupting current playback
// preserveOrder=true will skip shuffle index generation (used for vibe mode)
const setUpcoming = useCallback(
(tracks: Track[], preserveOrder = false) => {
if (!state.currentTrack || state.playbackType !== "track") {
// No current track, just start playing the new tracks
if (tracks.length > 0) {
state.setQueue(tracks);
state.setCurrentIndex(0);
state.setCurrentTrack(tracks[0]);
state.setPlaybackType("track");
playback.setIsPlaying(true);
playback.setCurrentTime(0);
// Don't generate shuffle indices if preserving order (vibe mode)
if (!preserveOrder && !state.vibeMode) {
state.setShuffleIndices(
generateShuffleIndices(tracks.length, 0)
);
} else {
state.setShuffleIndices([]);
}
}
return;
}
// Keep current track, replace everything after it
state.setQueue((prev) => {
const currentTrack = prev[state.currentIndex];
if (!currentTrack) return tracks;
// New queue: current track + new tracks
return [currentTrack, ...tracks];
});
// Reset index to 0 (current track is now at index 0)
state.setCurrentIndex(0);
// Update shuffle indices for new queue
// Skip if preserveOrder=true (vibe mode) or already in vibe mode
if (state.isShuffle && !preserveOrder && !state.vibeMode) {
state.setShuffleIndices(
generateShuffleIndices(tracks.length + 1, 0)
);
} else {
// Clear shuffle indices for vibe mode or non-shuffle
state.setShuffleIndices([]);
}
},
[state, playback, generateShuffleIndices]
);
const toggleShuffle = useCallback(() => {
// Don't allow shuffle to be enabled while in vibe mode
// Vibe queue is sorted by match %, shuffle would break that order
if (state.vibeMode) {
return;
}
state.setIsShuffle((prev) => {
const newShuffle = !prev;
if (newShuffle && state.queue.length > 0) {
state.setShuffleIndices(
generateShuffleIndices(
state.queue.length,
state.currentIndex
)
);
}
return newShuffle;
});
}, [state, generateShuffleIndices]);
const toggleRepeat = useCallback(() => {
state.setRepeatMode((prev) => {
if (prev === "off") return "all";
if (prev === "all") return "one";
return "off";
});
state.setRepeatOneCount(0);
}, [state]);
const updateCurrentTime = useCallback(
(time: number) => {
playback.setCurrentTime(time);
},
[playback]
);
const seek = useCallback(
(time: number) => {
// Prefer canonical durations for long-form media. If both exist, take the safer minimum.
const mediaDuration =
state.playbackType === "podcast"
? state.currentPodcast?.duration || 0
: state.playbackType === "audiobook"
? state.currentAudiobook?.duration || 0
: state.currentTrack?.duration || 0;
const maxDuration =
mediaDuration > 0 && playback.duration > 0
? Math.min(mediaDuration, playback.duration)
: mediaDuration || playback.duration || 0;
const clampedTime =
maxDuration > 0
? Math.min(Math.max(time, 0), maxDuration)
: Math.max(time, 0);
// Lock seek to prevent stale timeupdate events from overwriting optimistic update
// This is especially important for podcasts where seeking may require audio reload
playback.lockSeek(clampedTime);
// Optimistically update local playback time for instant UI feedback
playback.setCurrentTime(clampedTime);
// Keep audiobook/podcast progress in sync locally so detail pages reflect scrubs
if (state.playbackType === "audiobook" && state.currentAudiobook) {
// IMPORTANT: use functional update to avoid stale-closure overwrites
// (seeking must never be able to swap the currently-playing audiobook)
state.setCurrentAudiobook((prev) => {
if (!prev) return prev;
const duration = prev.duration || 0;
const progressPercent =
duration > 0 ? (clampedTime / duration) * 100 : 0;
return {
...prev,
progress: {
currentTime: clampedTime,
progress: progressPercent,
isFinished: false,
lastPlayedAt: new Date(),
},
};
});
} else if (
state.playbackType === "podcast" &&
state.currentPodcast
) {
// IMPORTANT: use functional update to avoid stale-closure overwrites
// (seeking must never be able to swap the currently-playing episode)
state.setCurrentPodcast((prev) => {
if (!prev) return prev;
const duration = prev.duration || 0;
const progressPercent =
duration > 0 ? (clampedTime / duration) * 100 : 0;
return {
...prev,
progress: {
currentTime: clampedTime,
progress: progressPercent,
isFinished: false,
lastPlayedAt: new Date(),
},
};
});
}
audioSeekEmitter.emit(clampedTime);
},
[playback, state]
);
const skipForward = useCallback(
(seconds: number = 30) => {
seek(playback.currentTime + seconds);
},
[playback.currentTime, seek]
);
const skipBackward = useCallback(
(seconds: number = 30) => {
seek(playback.currentTime - seconds);
},
[playback.currentTime, seek]
);
const setPlayerModeWithHistory = useCallback(
(mode: PlayerMode) => {
state.setPreviousPlayerMode(state.playerMode);
state.setPlayerMode(mode);
},
[state]
);
const returnToPreviousMode = useCallback(() => {
const targetMode =
state.playerMode === "overlay" ? "mini" : state.previousPlayerMode;
const temp = state.playerMode;
state.setPlayerMode(targetMode);
state.setPreviousPlayerMode(temp);
}, [state]);
const setVolumeControl = useCallback(
(newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume));
state.setVolume(clampedVolume);
if (clampedVolume > 0) {
state.setIsMuted(false);
}
},
[state]
);
const toggleMute = useCallback(() => {
state.setIsMuted((prev) => !prev);
}, [state]);
// Vibe mode controls
const startVibeMode = useCallback(
(
sourceFeatures: {
bpm?: number | null;
energy?: number | null;
valence?: number | null;
arousal?: number | null;
danceability?: number | null;
keyScale?: string | null;
instrumentalness?: number | null;
analysisMode?: string | null;
// ML Mood predictions
moodHappy?: number | null;
moodSad?: number | null;
moodRelaxed?: number | null;
moodAggressive?: number | null;
moodParty?: number | null;
moodAcoustic?: number | null;
moodElectronic?: number | null;
},
queueIds: string[]
) => {
// Disable shuffle when vibe mode starts - vibe queue is sorted by match %
state.setIsShuffle(false);
state.setShuffleIndices([]);
state.setVibeMode(true);
state.setVibeSourceFeatures(sourceFeatures);
state.setVibeQueueIds(queueIds);
},
[state]
);
const stopVibeMode = useCallback(() => {
state.setVibeMode(false);
state.setVibeSourceFeatures(null);
state.setVibeQueueIds([]);
}, [state]);
// Memoize the entire context value
const value = useMemo(
() => ({
playTrack,
playTracks,
playAudiobook,
playPodcast,
nextPodcastEpisode,
pause,
resume,
play,
next,
previous,
addToQueue,
removeFromQueue,
clearQueue,
setUpcoming,
toggleShuffle,
toggleRepeat,
updateCurrentTime,
seek,
skipForward,
skipBackward,
setPlayerMode: setPlayerModeWithHistory,
returnToPreviousMode,
setVolume: setVolumeControl,
toggleMute,
startVibeMode,
stopVibeMode,
}),
[
playTrack,
playTracks,
playAudiobook,
playPodcast,
nextPodcastEpisode,
pause,
resume,
play,
next,
previous,
addToQueue,
removeFromQueue,
clearQueue,
setUpcoming,
toggleShuffle,
toggleRepeat,
updateCurrentTime,
seek,
skipForward,
skipBackward,
setPlayerModeWithHistory,
returnToPreviousMode,
setVolumeControl,
toggleMute,
startVibeMode,
stopVibeMode,
]
);
return (
<AudioControlsContext.Provider value={value}>
{children}
</AudioControlsContext.Provider>
);
}
export function useAudioControls() {
const context = useContext(AudioControlsContext);
if (!context) {
throw new Error(
"useAudioControls must be used within AudioControlsProvider"
);
}
return context;
}