Initial release v1.0.0
This commit is contained in:
+1480
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Audio Context Re-exports
|
||||
*
|
||||
* All functionality is split into three separate contexts for better performance:
|
||||
*
|
||||
* - audio-state-context.tsx - Rarely changing data (currentTrack, queue, etc.)
|
||||
* - audio-playback-context.tsx - Frequently changing data (currentTime, isPlaying)
|
||||
* - audio-controls-context.tsx - Actions only (play, pause, next, etc.)
|
||||
*
|
||||
* Import from these files directly for optimal performance.
|
||||
* This file provides convenient re-exports for backward compatibility.
|
||||
*/
|
||||
|
||||
// Re-export types
|
||||
export type { PlayerMode, Track, Audiobook, Podcast, AudioFeatures } from "./audio-state-context";
|
||||
|
||||
// Re-export providers
|
||||
export { AudioStateProvider } from "./audio-state-context";
|
||||
export { AudioPlaybackProvider } from "./audio-playback-context";
|
||||
export { AudioControlsProvider } from "./audio-controls-context";
|
||||
|
||||
// Re-export individual hooks
|
||||
export { useAudioState } from "./audio-state-context";
|
||||
export { useAudioPlayback } from "./audio-playback-context";
|
||||
export { useAudioControls } from "./audio-controls-context";
|
||||
|
||||
// Re-export the unified hook (backward compatibility)
|
||||
export { useAudio } from "./audio-hooks";
|
||||
@@ -0,0 +1,874 @@
|
||||
"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;
|
||||
|
||||
// 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.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.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.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 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);
|
||||
|
||||
// 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,
|
||||
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,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useAudioState } from "./audio-state-context";
|
||||
import { useAudioPlayback } from "./audio-playback-context";
|
||||
import { useAudioControls } from "./audio-controls-context";
|
||||
|
||||
/**
|
||||
* Unified hook that combines all audio contexts.
|
||||
* Use this for backward compatibility with existing code.
|
||||
*
|
||||
* For optimal performance, prefer using the individual hooks:
|
||||
* - useAudioState() - for rarely changing data (currentTrack, queue, etc.)
|
||||
* - useAudioPlayback() - for frequently changing data (currentTime, isPlaying)
|
||||
* - useAudioControls() - for actions only (play, pause, next, etc.)
|
||||
*/
|
||||
export function useAudio() {
|
||||
const state = useAudioState();
|
||||
const playback = useAudioPlayback();
|
||||
const controls = useAudioControls();
|
||||
|
||||
return {
|
||||
// State
|
||||
currentTrack: state.currentTrack,
|
||||
currentAudiobook: state.currentAudiobook,
|
||||
currentPodcast: state.currentPodcast,
|
||||
playbackType: state.playbackType,
|
||||
queue: state.queue,
|
||||
currentIndex: state.currentIndex,
|
||||
isShuffle: state.isShuffle,
|
||||
isRepeat: state.isRepeat,
|
||||
repeatMode: state.repeatMode,
|
||||
playerMode: state.playerMode,
|
||||
volume: state.volume,
|
||||
isMuted: state.isMuted,
|
||||
|
||||
// Vibe mode state
|
||||
vibeMode: state.vibeMode,
|
||||
vibeSourceFeatures: state.vibeSourceFeatures,
|
||||
vibeQueueIds: state.vibeQueueIds,
|
||||
|
||||
// Playback
|
||||
isPlaying: playback.isPlaying,
|
||||
currentTime: playback.currentTime,
|
||||
duration: playback.duration,
|
||||
isBuffering: playback.isBuffering,
|
||||
targetSeekPosition: playback.targetSeekPosition,
|
||||
canSeek: playback.canSeek,
|
||||
downloadProgress: playback.downloadProgress,
|
||||
|
||||
// Controls
|
||||
playTrack: controls.playTrack,
|
||||
playTracks: controls.playTracks,
|
||||
playAudiobook: controls.playAudiobook,
|
||||
playPodcast: controls.playPodcast,
|
||||
pause: controls.pause,
|
||||
resume: controls.resume,
|
||||
next: controls.next,
|
||||
previous: controls.previous,
|
||||
addToQueue: controls.addToQueue,
|
||||
removeFromQueue: controls.removeFromQueue,
|
||||
clearQueue: controls.clearQueue,
|
||||
setUpcoming: controls.setUpcoming,
|
||||
toggleShuffle: controls.toggleShuffle,
|
||||
toggleRepeat: controls.toggleRepeat,
|
||||
updateCurrentTime: controls.updateCurrentTime,
|
||||
seek: controls.seek,
|
||||
skipForward: controls.skipForward,
|
||||
skipBackward: controls.skipBackward,
|
||||
setPlayerMode: controls.setPlayerMode,
|
||||
returnToPreviousMode: controls.returnToPreviousMode,
|
||||
setVolume: controls.setVolume,
|
||||
toggleMute: controls.toggleMute,
|
||||
|
||||
// Vibe mode controls
|
||||
startVibeMode: controls.startVibeMode,
|
||||
stopVibeMode: controls.stopVibeMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
interface AudioPlaybackContextType {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isBuffering: boolean;
|
||||
targetSeekPosition: number | null;
|
||||
canSeek: boolean;
|
||||
downloadProgress: number | null; // 0-100 for downloading, null for not downloading
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setIsBuffering: (buffering: boolean) => void;
|
||||
setTargetSeekPosition: (position: number | null) => void;
|
||||
setCanSeek: (canSeek: boolean) => void;
|
||||
setDownloadProgress: (progress: number | null) => void;
|
||||
}
|
||||
|
||||
const AudioPlaybackContext = createContext<
|
||||
AudioPlaybackContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
// LocalStorage keys
|
||||
const STORAGE_KEYS = {
|
||||
IS_PLAYING: "lidify_is_playing",
|
||||
CURRENT_TIME: "lidify_current_time",
|
||||
};
|
||||
|
||||
export function AudioPlaybackProvider({ children }: { children: ReactNode }) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isBuffering, setIsBuffering] = useState(false);
|
||||
const [targetSeekPosition, setTargetSeekPosition] = useState<number | null>(null);
|
||||
const [canSeek, setCanSeek] = useState(true); // Default true for music, false for uncached podcasts
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
|
||||
// Restore currentTime from localStorage on mount
|
||||
// NOTE: Do NOT touch isPlaying here - let user actions control it
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const savedTime = localStorage.getItem(STORAGE_KEYS.CURRENT_TIME);
|
||||
if (savedTime) setCurrentTime(parseFloat(savedTime));
|
||||
// Don't force pause - this was causing immediate pause after play!
|
||||
} catch (error) {
|
||||
console.error("[AudioPlayback] Failed to restore state:", error);
|
||||
}
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Save currentTime to localStorage (throttled to avoid excessive writes)
|
||||
useEffect(() => {
|
||||
if (!isHydrated || typeof window === "undefined") return;
|
||||
|
||||
// Throttle saves to every 5 seconds using timestamp comparison
|
||||
const now = Date.now();
|
||||
if (now - lastSaveTimeRef.current < 5000) return;
|
||||
|
||||
lastSaveTimeRef.current = now;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.CURRENT_TIME,
|
||||
currentTime.toString()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[AudioPlayback] Failed to save currentTime:", error);
|
||||
}
|
||||
}, [currentTime, isHydrated]);
|
||||
|
||||
// Memoize to prevent re-renders when values haven't changed
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
isBuffering,
|
||||
targetSeekPosition,
|
||||
canSeek,
|
||||
downloadProgress,
|
||||
setIsPlaying,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setIsBuffering,
|
||||
setTargetSeekPosition,
|
||||
setCanSeek,
|
||||
setDownloadProgress,
|
||||
}),
|
||||
[isPlaying, currentTime, duration, isBuffering, targetSeekPosition, canSeek, downloadProgress]
|
||||
);
|
||||
|
||||
return (
|
||||
<AudioPlaybackContext.Provider value={value}>
|
||||
{children}
|
||||
</AudioPlaybackContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAudioPlayback() {
|
||||
const context = useContext(AudioPlaybackContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAudioPlayback must be used within AudioPlaybackProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Simple event emitter for seeking without causing re-renders
|
||||
* This allows the seek() function to communicate with AudioElement
|
||||
* without subscribing to currentTime state changes
|
||||
*/
|
||||
|
||||
type SeekListener = (time: number) => void;
|
||||
|
||||
class AudioSeekEmitter {
|
||||
private listeners: Set<SeekListener> = new Set();
|
||||
|
||||
public subscribe(listener: SeekListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public emit(time: number): void {
|
||||
this.listeners.forEach((listener) => listener(time));
|
||||
}
|
||||
}
|
||||
|
||||
export const audioSeekEmitter = new AudioSeekEmitter();
|
||||
@@ -0,0 +1,737 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
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 || {});
|
||||
}
|
||||
|
||||
export type PlayerMode = "full" | "mini" | "overlay";
|
||||
|
||||
// Audio features for vibe mode visualization
|
||||
export interface AudioFeatures {
|
||||
bpm?: number | null;
|
||||
energy?: number | null;
|
||||
valence?: number | null;
|
||||
arousal?: number | null;
|
||||
danceability?: number | null;
|
||||
keyScale?: string | null;
|
||||
instrumentalness?: number | null;
|
||||
// ML Mood predictions (Enhanced mode)
|
||||
moodHappy?: number | null;
|
||||
moodSad?: number | null;
|
||||
moodRelaxed?: number | null;
|
||||
moodAggressive?: number | null;
|
||||
moodParty?: number | null;
|
||||
moodAcoustic?: number | null;
|
||||
moodElectronic?: number | null;
|
||||
analysisMode?: string | null;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: { name: string; id?: string; mbid?: string };
|
||||
album: { title: string; coverArt?: string; id?: string };
|
||||
duration: number;
|
||||
filePath?: string;
|
||||
// Audio features for vibe mode visualization
|
||||
audioFeatures?: {
|
||||
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;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Audiobook {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
narrator?: string;
|
||||
coverUrl: string | null;
|
||||
duration: number;
|
||||
progress?: {
|
||||
currentTime: number;
|
||||
progress: number;
|
||||
isFinished: boolean;
|
||||
lastPlayedAt: Date;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Podcast {
|
||||
id: string; // Format: "podcastId:episodeId"
|
||||
title: string;
|
||||
podcastTitle: string;
|
||||
coverUrl: string | null;
|
||||
duration: number;
|
||||
progress?: {
|
||||
currentTime: number;
|
||||
progress: number;
|
||||
isFinished: boolean;
|
||||
lastPlayedAt: Date;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type SetStateAction<T> = T | ((prev: T) => T);
|
||||
|
||||
interface AudioStateContextType {
|
||||
// Media state
|
||||
currentTrack: Track | null;
|
||||
currentAudiobook: Audiobook | null;
|
||||
currentPodcast: Podcast | null;
|
||||
playbackType: "track" | "audiobook" | "podcast" | null;
|
||||
|
||||
// Queue state
|
||||
queue: Track[];
|
||||
currentIndex: number;
|
||||
isShuffle: boolean;
|
||||
repeatMode: "off" | "one" | "all";
|
||||
isRepeat: boolean;
|
||||
shuffleIndices: number[];
|
||||
|
||||
// UI state
|
||||
playerMode: PlayerMode;
|
||||
previousPlayerMode: PlayerMode;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
|
||||
// Vibe mode state
|
||||
vibeMode: boolean;
|
||||
vibeSourceFeatures: AudioFeatures | null;
|
||||
vibeQueueIds: string[];
|
||||
|
||||
// Internal state
|
||||
isHydrated: boolean;
|
||||
lastServerSync: Date | null;
|
||||
repeatOneCount: number;
|
||||
|
||||
// State setters (for controls context)
|
||||
setCurrentTrack: (track: SetStateAction<Track | null>) => void;
|
||||
setCurrentAudiobook: (audiobook: SetStateAction<Audiobook | null>) => void;
|
||||
setCurrentPodcast: (podcast: SetStateAction<Podcast | null>) => void;
|
||||
setPlaybackType: (
|
||||
type: SetStateAction<"track" | "audiobook" | "podcast" | null>
|
||||
) => void;
|
||||
setQueue: (queue: SetStateAction<Track[]>) => void;
|
||||
setCurrentIndex: (index: SetStateAction<number>) => void;
|
||||
setIsShuffle: (shuffle: SetStateAction<boolean>) => void;
|
||||
setRepeatMode: (mode: SetStateAction<"off" | "one" | "all">) => void;
|
||||
setShuffleIndices: (indices: SetStateAction<number[]>) => void;
|
||||
setPlayerMode: (mode: SetStateAction<PlayerMode>) => void;
|
||||
setPreviousPlayerMode: (mode: SetStateAction<PlayerMode>) => void;
|
||||
setVolume: (volume: SetStateAction<number>) => void;
|
||||
setIsMuted: (muted: SetStateAction<boolean>) => void;
|
||||
setLastServerSync: (date: SetStateAction<Date | null>) => void;
|
||||
setRepeatOneCount: (count: SetStateAction<number>) => void;
|
||||
setVibeMode: (mode: SetStateAction<boolean>) => void;
|
||||
setVibeSourceFeatures: (features: SetStateAction<AudioFeatures | null>) => void;
|
||||
setVibeQueueIds: (ids: SetStateAction<string[]>) => void;
|
||||
}
|
||||
|
||||
const AudioStateContext = createContext<AudioStateContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
// LocalStorage keys
|
||||
const STORAGE_KEYS = {
|
||||
CURRENT_TRACK: "lidify_current_track",
|
||||
CURRENT_AUDIOBOOK: "lidify_current_audiobook",
|
||||
CURRENT_PODCAST: "lidify_current_podcast",
|
||||
PLAYBACK_TYPE: "lidify_playback_type",
|
||||
QUEUE: "lidify_queue",
|
||||
CURRENT_INDEX: "lidify_current_index",
|
||||
IS_SHUFFLE: "lidify_is_shuffle",
|
||||
REPEAT_MODE: "lidify_repeat_mode",
|
||||
PLAYER_MODE: "lidify_player_mode",
|
||||
VOLUME: "lidify_volume",
|
||||
IS_MUTED: "lidify_muted",
|
||||
};
|
||||
|
||||
export function AudioStateProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||
const [currentAudiobook, setCurrentAudiobook] = useState<Audiobook | null>(
|
||||
null
|
||||
);
|
||||
const [currentPodcast, setCurrentPodcast] = useState<Podcast | null>(null);
|
||||
const [playbackType, setPlaybackType] = useState<
|
||||
"track" | "audiobook" | "podcast" | null
|
||||
>(null);
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isShuffle, setIsShuffle] = useState(false);
|
||||
const [shuffleIndices, setShuffleIndices] = useState<number[]>([]);
|
||||
const [repeatMode, setRepeatMode] = useState<"off" | "one" | "all">("off");
|
||||
const [repeatOneCount, setRepeatOneCount] = useState(0);
|
||||
const [playerMode, setPlayerMode] = useState<PlayerMode>("full");
|
||||
const [previousPlayerMode, setPreviousPlayerMode] =
|
||||
useState<PlayerMode>("full");
|
||||
const [volume, setVolume] = useState(0.5); // Default to 50%
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const [lastServerSync, setLastServerSync] = useState<Date | null>(null);
|
||||
|
||||
// Vibe mode state
|
||||
const [vibeMode, setVibeMode] = useState(false);
|
||||
const [vibeSourceFeatures, setVibeSourceFeatures] = useState<AudioFeatures | null>(null);
|
||||
const [vibeQueueIds, setVibeQueueIds] = useState<string[]>([]);
|
||||
|
||||
// Restore state from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const savedTrack = localStorage.getItem(STORAGE_KEYS.CURRENT_TRACK);
|
||||
const savedAudiobook = localStorage.getItem(
|
||||
STORAGE_KEYS.CURRENT_AUDIOBOOK
|
||||
);
|
||||
const savedPodcast = localStorage.getItem(
|
||||
STORAGE_KEYS.CURRENT_PODCAST
|
||||
);
|
||||
const savedPlaybackType = localStorage.getItem(
|
||||
STORAGE_KEYS.PLAYBACK_TYPE
|
||||
);
|
||||
const savedQueue = localStorage.getItem(STORAGE_KEYS.QUEUE);
|
||||
const savedIndex = localStorage.getItem(STORAGE_KEYS.CURRENT_INDEX);
|
||||
const savedShuffle = localStorage.getItem(STORAGE_KEYS.IS_SHUFFLE);
|
||||
const savedRepeatMode = localStorage.getItem(
|
||||
STORAGE_KEYS.REPEAT_MODE
|
||||
);
|
||||
const savedPlayerMode = localStorage.getItem(
|
||||
STORAGE_KEYS.PLAYER_MODE
|
||||
);
|
||||
const savedVolume = localStorage.getItem(STORAGE_KEYS.VOLUME);
|
||||
const savedMuted = localStorage.getItem(STORAGE_KEYS.IS_MUTED);
|
||||
|
||||
if (savedTrack) setCurrentTrack(JSON.parse(savedTrack));
|
||||
|
||||
// For audiobooks, restore then fetch fresh progress
|
||||
if (savedAudiobook) {
|
||||
const audiobookData = JSON.parse(savedAudiobook);
|
||||
setCurrentAudiobook(audiobookData);
|
||||
|
||||
api.getAudiobook(audiobookData.id)
|
||||
.then((audiobook: any) => {
|
||||
if (audiobook && audiobook.progress) {
|
||||
setCurrentAudiobook({
|
||||
...audiobookData,
|
||||
progress: audiobook.progress,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(
|
||||
"[AudioState] Failed to refresh audiobook progress:",
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// For podcasts, restore then fetch fresh progress
|
||||
if (savedPodcast) {
|
||||
const podcastData = JSON.parse(savedPodcast);
|
||||
setCurrentPodcast(podcastData);
|
||||
|
||||
const [podcastId, episodeId] = podcastData.id.split(":");
|
||||
if (podcastId && episodeId) {
|
||||
api.getPodcast(podcastId)
|
||||
.then((podcast: any) => {
|
||||
const episode = podcast.episodes?.find(
|
||||
(ep: any) => ep.id === episodeId
|
||||
);
|
||||
if (episode && episode.progress) {
|
||||
setCurrentPodcast({
|
||||
...podcastData,
|
||||
progress: episode.progress,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(
|
||||
"[AudioState] Failed to refresh podcast progress:",
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (savedPlaybackType)
|
||||
setPlaybackType(
|
||||
savedPlaybackType as "track" | "audiobook" | "podcast"
|
||||
);
|
||||
if (savedQueue) setQueue(JSON.parse(savedQueue));
|
||||
if (savedIndex) setCurrentIndex(parseInt(savedIndex));
|
||||
if (savedShuffle) setIsShuffle(savedShuffle === "true");
|
||||
if (savedRepeatMode)
|
||||
setRepeatMode(savedRepeatMode as "off" | "one" | "all");
|
||||
if (savedVolume) setVolume(parseFloat(savedVolume));
|
||||
if (savedMuted) setIsMuted(savedMuted === "true");
|
||||
if (savedPlayerMode) setPlayerMode(savedPlayerMode as PlayerMode);
|
||||
} catch (error) {
|
||||
console.error("[AudioState] Failed to restore state:", error);
|
||||
}
|
||||
setIsHydrated(true);
|
||||
|
||||
// Load playback state from server
|
||||
api.getPlaybackState()
|
||||
.then((serverState) => {
|
||||
if (!serverState) return;
|
||||
|
||||
if (
|
||||
serverState.playbackType === "track" &&
|
||||
serverState.trackId
|
||||
) {
|
||||
api.getTrack(serverState.trackId)
|
||||
.then((track) => {
|
||||
setCurrentTrack(track);
|
||||
setPlaybackType("track");
|
||||
setCurrentAudiobook(null);
|
||||
setCurrentPodcast(null);
|
||||
})
|
||||
.catch(() => {
|
||||
api.clearPlaybackState().catch(() => {});
|
||||
setCurrentTrack(null);
|
||||
setCurrentAudiobook(null);
|
||||
setCurrentPodcast(null);
|
||||
setPlaybackType(null);
|
||||
setQueue([]);
|
||||
setCurrentIndex(0);
|
||||
});
|
||||
} else if (
|
||||
serverState.playbackType === "audiobook" &&
|
||||
serverState.audiobookId
|
||||
) {
|
||||
api.getAudiobook(serverState.audiobookId).then(
|
||||
(audiobook) => {
|
||||
setCurrentAudiobook(audiobook);
|
||||
setPlaybackType("audiobook");
|
||||
setCurrentTrack(null);
|
||||
setCurrentPodcast(null);
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
serverState.playbackType === "podcast" &&
|
||||
serverState.podcastId
|
||||
) {
|
||||
const [podcastId, episodeId] =
|
||||
serverState.podcastId.split(":");
|
||||
api.getPodcast(podcastId).then((podcast) => {
|
||||
const episode = podcast.episodes?.find(
|
||||
(ep: any) => ep.id === episodeId
|
||||
);
|
||||
if (episode) {
|
||||
setCurrentPodcast({
|
||||
id: serverState.podcastId,
|
||||
title: episode.title,
|
||||
podcastTitle: podcast.title,
|
||||
coverUrl: podcast.coverUrl,
|
||||
duration: episode.duration,
|
||||
progress: episode.progress,
|
||||
});
|
||||
setPlaybackType("podcast");
|
||||
setCurrentTrack(null);
|
||||
setCurrentAudiobook(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (serverState.queue) setQueue(serverState.queue);
|
||||
if (serverState.currentIndex !== undefined)
|
||||
setCurrentIndex(serverState.currentIndex);
|
||||
if (serverState.isShuffle !== undefined)
|
||||
setIsShuffle(serverState.isShuffle);
|
||||
})
|
||||
.catch(() => {
|
||||
// No server state available - this is expected on first load
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (!isHydrated || typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
if (currentTrack) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.CURRENT_TRACK,
|
||||
JSON.stringify(currentTrack)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.CURRENT_TRACK);
|
||||
}
|
||||
if (currentAudiobook) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.CURRENT_AUDIOBOOK,
|
||||
JSON.stringify(currentAudiobook)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.CURRENT_AUDIOBOOK);
|
||||
}
|
||||
if (currentPodcast) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.CURRENT_PODCAST,
|
||||
JSON.stringify(currentPodcast)
|
||||
);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.CURRENT_PODCAST);
|
||||
}
|
||||
if (playbackType) {
|
||||
localStorage.setItem(STORAGE_KEYS.PLAYBACK_TYPE, playbackType);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEYS.PLAYBACK_TYPE);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEYS.QUEUE, JSON.stringify(queue));
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.CURRENT_INDEX,
|
||||
currentIndex.toString()
|
||||
);
|
||||
localStorage.setItem(STORAGE_KEYS.IS_SHUFFLE, isShuffle.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.REPEAT_MODE, repeatMode);
|
||||
localStorage.setItem(STORAGE_KEYS.PLAYER_MODE, playerMode);
|
||||
localStorage.setItem(STORAGE_KEYS.VOLUME, volume.toString());
|
||||
localStorage.setItem(STORAGE_KEYS.IS_MUTED, isMuted.toString());
|
||||
} catch (error) {
|
||||
console.error("[AudioState] Failed to save state:", error);
|
||||
}
|
||||
}, [
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
queue,
|
||||
currentIndex,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
playerMode,
|
||||
volume,
|
||||
isMuted,
|
||||
isHydrated,
|
||||
]);
|
||||
|
||||
// Save playback state to server
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return;
|
||||
if (!playbackType) return;
|
||||
|
||||
const saveToServer = async () => {
|
||||
try {
|
||||
// Limit queue to first 100 items to reduce payload size
|
||||
// Backend also limits to 100, so this matches server storage
|
||||
const limitedQueue = queue?.slice(0, 100);
|
||||
const adjustedIndex = Math.min(
|
||||
currentIndex,
|
||||
(limitedQueue?.length || 1) - 1
|
||||
);
|
||||
|
||||
const result = await api.savePlaybackState({
|
||||
playbackType,
|
||||
trackId: currentTrack?.id,
|
||||
audiobookId: currentAudiobook?.id,
|
||||
podcastId: currentPodcast?.id,
|
||||
queue: limitedQueue,
|
||||
currentIndex: adjustedIndex,
|
||||
isShuffle,
|
||||
});
|
||||
setLastServerSync(new Date(result.updatedAt));
|
||||
queueDebugLog("Saved playback state to server", {
|
||||
playbackType,
|
||||
trackId: currentTrack?.id,
|
||||
queueLen: limitedQueue?.length || 0,
|
||||
currentIndex: adjustedIndex,
|
||||
isShuffle,
|
||||
updatedAt: result.updatedAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.message !== "Not authenticated") {
|
||||
console.error(
|
||||
"[AudioState] Failed to save to server:",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(saveToServer, 1000);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
playbackType,
|
||||
currentTrack?.id,
|
||||
currentAudiobook?.id,
|
||||
currentPodcast?.id,
|
||||
queue,
|
||||
currentIndex,
|
||||
isShuffle,
|
||||
isHydrated,
|
||||
]);
|
||||
|
||||
// Poll server for changes from other devices (pauses when tab is hidden)
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
let isAuthenticated = true;
|
||||
let mounted = true;
|
||||
let isVisible = !document.hidden;
|
||||
|
||||
// Handle visibility changes to save battery/resources
|
||||
const handleVisibilityChange = () => {
|
||||
isVisible = !document.hidden;
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
// Skip polling when tab is hidden, unmounted, or not authenticated
|
||||
if (!isAuthenticated || !mounted || !isVisible) return;
|
||||
|
||||
try {
|
||||
const serverState = await api.getPlaybackState();
|
||||
if (!serverState || !mounted) return;
|
||||
|
||||
const serverUpdatedAt = new Date(serverState.updatedAt);
|
||||
|
||||
if (lastServerSync && serverUpdatedAt <= lastServerSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverMediaId =
|
||||
serverState.trackId ||
|
||||
serverState.audiobookId ||
|
||||
serverState.podcastId;
|
||||
const currentMediaId =
|
||||
currentTrack?.id ||
|
||||
currentAudiobook?.id ||
|
||||
currentPodcast?.id;
|
||||
|
||||
if (
|
||||
serverMediaId !== currentMediaId ||
|
||||
serverState.playbackType !== playbackType
|
||||
) {
|
||||
if (
|
||||
serverState.playbackType === "track" &&
|
||||
serverState.trackId
|
||||
) {
|
||||
try {
|
||||
const track = await api.getTrack(
|
||||
serverState.trackId
|
||||
);
|
||||
if (!mounted) return;
|
||||
setCurrentTrack(track);
|
||||
setPlaybackType("track");
|
||||
setCurrentAudiobook(null);
|
||||
setCurrentPodcast(null);
|
||||
if (
|
||||
serverState.queue &&
|
||||
serverState.queue.length > 0
|
||||
) {
|
||||
setQueue(serverState.queue);
|
||||
setCurrentIndex(serverState.currentIndex || 0);
|
||||
setIsShuffle(serverState.isShuffle || false);
|
||||
}
|
||||
} catch (trackErr) {
|
||||
if (!mounted) return;
|
||||
await api.clearPlaybackState().catch(() => {});
|
||||
setCurrentTrack(null);
|
||||
setCurrentAudiobook(null);
|
||||
setCurrentPodcast(null);
|
||||
setPlaybackType(null);
|
||||
setQueue([]);
|
||||
setCurrentIndex(0);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
serverState.playbackType === "audiobook" &&
|
||||
serverState.audiobookId
|
||||
) {
|
||||
const audiobook = await api.getAudiobook(
|
||||
serverState.audiobookId
|
||||
);
|
||||
if (!mounted) return;
|
||||
setCurrentAudiobook(audiobook);
|
||||
setPlaybackType("audiobook");
|
||||
setCurrentTrack(null);
|
||||
setCurrentPodcast(null);
|
||||
} else if (
|
||||
serverState.playbackType === "podcast" &&
|
||||
serverState.podcastId
|
||||
) {
|
||||
const [podcastId, episodeId] =
|
||||
serverState.podcastId.split(":");
|
||||
const podcast = await api.getPodcast(podcastId);
|
||||
if (!mounted) return;
|
||||
const episode = podcast.episodes?.find(
|
||||
(ep: any) => ep.id === episodeId
|
||||
);
|
||||
if (episode) {
|
||||
setCurrentPodcast({
|
||||
id: serverState.podcastId,
|
||||
title: episode.title,
|
||||
podcastTitle: podcast.title,
|
||||
coverUrl: podcast.coverUrl,
|
||||
duration: episode.duration,
|
||||
progress: episode.progress,
|
||||
});
|
||||
setPlaybackType("podcast");
|
||||
setCurrentTrack(null);
|
||||
setCurrentAudiobook(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
if (
|
||||
JSON.stringify(serverState.queue) !==
|
||||
JSON.stringify(queue)
|
||||
) {
|
||||
queueDebugLog("Polling applied server queue", {
|
||||
serverQueueLen: serverState.queue?.length || 0,
|
||||
localQueueLen: queue?.length || 0,
|
||||
serverCurrentIndex: serverState.currentIndex || 0,
|
||||
localCurrentIndex: currentIndex,
|
||||
serverIsShuffle: serverState.isShuffle,
|
||||
localIsShuffle: isShuffle,
|
||||
serverUpdatedAt: serverState.updatedAt,
|
||||
});
|
||||
setQueue(serverState.queue || []);
|
||||
setCurrentIndex(serverState.currentIndex || 0);
|
||||
setIsShuffle(serverState.isShuffle || false);
|
||||
}
|
||||
|
||||
setLastServerSync(serverUpdatedAt);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message === "Not authenticated") {
|
||||
isAuthenticated = false;
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange
|
||||
);
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, [
|
||||
isHydrated,
|
||||
playbackType,
|
||||
currentTrack?.id,
|
||||
currentAudiobook?.id,
|
||||
currentPodcast?.id,
|
||||
queue,
|
||||
lastServerSync,
|
||||
]);
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
queue,
|
||||
currentIndex,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
isRepeat: repeatMode !== "off",
|
||||
shuffleIndices,
|
||||
playerMode,
|
||||
previousPlayerMode,
|
||||
volume,
|
||||
isMuted,
|
||||
vibeMode,
|
||||
vibeSourceFeatures,
|
||||
vibeQueueIds,
|
||||
isHydrated,
|
||||
lastServerSync,
|
||||
repeatOneCount,
|
||||
setCurrentTrack,
|
||||
setCurrentAudiobook,
|
||||
setCurrentPodcast,
|
||||
setPlaybackType,
|
||||
setQueue,
|
||||
setCurrentIndex,
|
||||
setIsShuffle,
|
||||
setRepeatMode,
|
||||
setShuffleIndices,
|
||||
setPlayerMode,
|
||||
setPreviousPlayerMode,
|
||||
setVolume,
|
||||
setIsMuted,
|
||||
setLastServerSync,
|
||||
setRepeatOneCount,
|
||||
setVibeMode,
|
||||
setVibeSourceFeatures,
|
||||
setVibeQueueIds,
|
||||
}),
|
||||
[
|
||||
currentTrack,
|
||||
currentAudiobook,
|
||||
currentPodcast,
|
||||
playbackType,
|
||||
queue,
|
||||
currentIndex,
|
||||
isShuffle,
|
||||
repeatMode,
|
||||
shuffleIndices,
|
||||
playerMode,
|
||||
previousPlayerMode,
|
||||
volume,
|
||||
isMuted,
|
||||
vibeMode,
|
||||
vibeSourceFeatures,
|
||||
vibeQueueIds,
|
||||
isHydrated,
|
||||
lastServerSync,
|
||||
repeatOneCount,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<AudioStateContext.Provider value={value}>
|
||||
{children}
|
||||
</AudioStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAudioState() {
|
||||
const context = useContext(AudioStateContext);
|
||||
if (!context) {
|
||||
throw new Error("useAudioState must be used within AudioStateProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { api } from "./api";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
onboardingComplete?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: User | null;
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
token?: string
|
||||
) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const publicPaths = ["/login", "/register", "/onboarding", "/sync"];
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has valid session on mount ONLY
|
||||
const checkAuth = async () => {
|
||||
// Check for token in URL (from redirect after login)
|
||||
if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tokenFromUrl = urlParams.get("token");
|
||||
if (tokenFromUrl) {
|
||||
// Store the token from URL
|
||||
api.setToken(tokenFromUrl);
|
||||
// Clean up URL (remove token param)
|
||||
const cleanUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", cleanUrl);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = await api.getCurrentUser();
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// Check onboarding status - redirect if needed
|
||||
if (
|
||||
userData.onboardingComplete === false &&
|
||||
pathname !== "/onboarding"
|
||||
) {
|
||||
router.push("/onboarding");
|
||||
} else if (
|
||||
userData.onboardingComplete &&
|
||||
pathname === "/onboarding"
|
||||
) {
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error) {
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
|
||||
// If we're already on onboarding page, allow access
|
||||
if (pathname === "/onboarding") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not on a public path, check if we need onboarding
|
||||
if (!publicPaths.includes(pathname)) {
|
||||
// Check if any users exist in the system
|
||||
try {
|
||||
const status = await api.get<{ hasAccount: boolean }>(
|
||||
"/onboarding/status"
|
||||
);
|
||||
|
||||
if (!status.hasAccount) {
|
||||
// No users exist - redirect to onboarding
|
||||
router.push("/onboarding");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If status check fails, assume users exist
|
||||
}
|
||||
// Users exist but not logged in - redirect to login
|
||||
router.push("/login");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only run once on mount
|
||||
|
||||
const login = async (
|
||||
username: string,
|
||||
password: string,
|
||||
token?: string
|
||||
) => {
|
||||
try {
|
||||
const userData = await api.login(username, password, token);
|
||||
|
||||
// Check if 2FA is required
|
||||
if (userData.requires2FA) {
|
||||
// Don't set user or redirect, just throw an error to trigger 2FA UI
|
||||
throw new Error("2FA token required");
|
||||
}
|
||||
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (userData.onboardingComplete === false) {
|
||||
router.push("/onboarding");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[AUTH] Login failed:", error.message);
|
||||
// Re-throw the error so the login page can handle it
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await api.logout();
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ isAuthenticated, isLoading, user, login, logout }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useDownloadStatus } from "@/hooks/useDownloadStatus";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
interface PendingDownload {
|
||||
id: string;
|
||||
type: "artist" | "album";
|
||||
subject: string;
|
||||
mbid: string; // Unique identifier for deduplication
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface DownloadContextType {
|
||||
pendingDownloads: PendingDownload[];
|
||||
downloadStatus: {
|
||||
activeDownloads: any[];
|
||||
recentDownloads: any[];
|
||||
hasActiveDownloads: boolean;
|
||||
failedDownloads: any[];
|
||||
};
|
||||
addPendingDownload: (
|
||||
type: "artist" | "album",
|
||||
subject: string,
|
||||
mbid: string
|
||||
) => string | null;
|
||||
removePendingDownload: (id: string) => void;
|
||||
removePendingByMbid: (mbid: string) => void;
|
||||
isPending: (subject: string) => boolean;
|
||||
isPendingByMbid: (mbid: string) => boolean;
|
||||
isAnyPending: () => boolean;
|
||||
}
|
||||
|
||||
const DownloadContext = createContext<DownloadContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function DownloadProvider({ children }: { children: ReactNode }) {
|
||||
const [pendingDownloads, setPendingDownloads] = useState<PendingDownload[]>(
|
||||
[]
|
||||
);
|
||||
const { isAuthenticated } = useAuth();
|
||||
const downloadStatus = useDownloadStatus(15000, isAuthenticated);
|
||||
|
||||
// Sync pending downloads with actual download status
|
||||
useEffect(() => {
|
||||
// Remove pending downloads that have completed or failed
|
||||
setPendingDownloads((prev) => {
|
||||
return prev.filter((pending) => {
|
||||
// Check if this MBID has a job that's completed or failed
|
||||
const matchingJob = [
|
||||
...downloadStatus.activeDownloads,
|
||||
...downloadStatus.recentDownloads,
|
||||
...downloadStatus.failedDownloads,
|
||||
].find((job) => job.targetMbid === pending.mbid);
|
||||
|
||||
// If job is completed or failed, remove from pending
|
||||
if (
|
||||
matchingJob &&
|
||||
(matchingJob.status === "completed" ||
|
||||
matchingJob.status === "failed")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep if still pending/processing or no job found yet
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}, [
|
||||
downloadStatus.activeDownloads,
|
||||
downloadStatus.recentDownloads,
|
||||
downloadStatus.failedDownloads,
|
||||
]);
|
||||
|
||||
const addPendingDownload = (
|
||||
type: "artist" | "album",
|
||||
subject: string,
|
||||
mbid: string
|
||||
): string | null => {
|
||||
// Check if already downloading this MBID
|
||||
if (pendingDownloads.some((d) => d.mbid === mbid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
const download: PendingDownload = {
|
||||
id,
|
||||
type,
|
||||
subject,
|
||||
mbid,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setPendingDownloads((prev) => [...prev, download]);
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const removePendingDownload = (id: string) => {
|
||||
setPendingDownloads((prev) => prev.filter((d) => d.id !== id));
|
||||
};
|
||||
|
||||
const removePendingByMbid = (mbid: string) => {
|
||||
setPendingDownloads((prev) => prev.filter((d) => d.mbid !== mbid));
|
||||
};
|
||||
|
||||
const isPending = (subject: string): boolean => {
|
||||
return pendingDownloads.some((d) => d.subject === subject);
|
||||
};
|
||||
|
||||
const isPendingByMbid = (mbid: string): boolean => {
|
||||
// Check both pending downloads AND active download jobs
|
||||
const isPendingLocal = pendingDownloads.some((d) => d.mbid === mbid);
|
||||
const hasActiveJob = downloadStatus.activeDownloads.some(
|
||||
(job) => job.targetMbid === mbid
|
||||
);
|
||||
|
||||
return isPendingLocal || hasActiveJob;
|
||||
};
|
||||
|
||||
const isAnyPending = (): boolean => {
|
||||
return pendingDownloads.length > 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<DownloadContext.Provider
|
||||
value={{
|
||||
pendingDownloads,
|
||||
downloadStatus,
|
||||
addPendingDownload,
|
||||
removePendingDownload,
|
||||
removePendingByMbid,
|
||||
isPending,
|
||||
isPendingByMbid,
|
||||
isAnyPending,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DownloadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDownloadContext() {
|
||||
const context = useContext(DownloadContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useDownloadContext must be used within DownloadProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* Howler.js Audio Engine
|
||||
*
|
||||
* Singleton manager for audio playback using Howler.js
|
||||
* Handles: play, pause, seek, volume, track changes, events
|
||||
*/
|
||||
|
||||
import { Howl } from "howler";
|
||||
|
||||
export type HowlerEventType =
|
||||
| "play"
|
||||
| "pause"
|
||||
| "stop"
|
||||
| "end"
|
||||
| "seek"
|
||||
| "volume"
|
||||
| "load"
|
||||
| "loaderror"
|
||||
| "playerror"
|
||||
| "timeupdate";
|
||||
|
||||
export type HowlerEventCallback = (data?: any) => void;
|
||||
|
||||
interface HowlerEngineState {
|
||||
currentSrc: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
}
|
||||
|
||||
class HowlerEngine {
|
||||
private howl: Howl | null = null;
|
||||
private timeUpdateInterval: NodeJS.Timeout | null = null;
|
||||
private eventListeners: Map<HowlerEventType, Set<HowlerEventCallback>> =
|
||||
new Map();
|
||||
private state: HowlerEngineState = {
|
||||
currentSrc: null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 1,
|
||||
isMuted: false,
|
||||
};
|
||||
private isLoading: boolean = false; // Guard against duplicate loads
|
||||
private userInitiatedPlay: boolean = false; // Track if play was user-initiated
|
||||
private retryCount: number = 0; // Track retry attempts
|
||||
private maxRetries: number = 3; // Max retry attempts for load errors
|
||||
private pendingAutoplay: boolean = false; // Track pending autoplay for retries
|
||||
private lastFormat: string | undefined; // Store format for retries
|
||||
private readonly popFadeMs: number = 10; // ms - micro-fade to reduce click/pop on track changes
|
||||
private shouldRetryLoads: boolean = false; // Only retry transient load errors where it helps (Android WebView)
|
||||
private cleanupTimeoutId: NodeJS.Timeout | null = null; // Track cleanup timeout to prevent race conditions
|
||||
|
||||
constructor() {
|
||||
// Initialize event listener maps
|
||||
const events: HowlerEventType[] = [
|
||||
"play",
|
||||
"pause",
|
||||
"stop",
|
||||
"end",
|
||||
"seek",
|
||||
"volume",
|
||||
"load",
|
||||
"loaderror",
|
||||
"playerror",
|
||||
"timeupdate",
|
||||
];
|
||||
events.forEach((event) => this.eventListeners.set(event, new Set()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and optionally play a new audio source
|
||||
* @param src - Audio URL
|
||||
* @param autoplay - Whether to auto-play after loading
|
||||
* @param format - Audio format hint (mp3, flac, etc.) - required for URLs without extensions
|
||||
*/
|
||||
load(
|
||||
src: string,
|
||||
autoplay: boolean = false,
|
||||
format?: string,
|
||||
isRetry: boolean = false
|
||||
): void {
|
||||
// Don't reload if same source and already loaded
|
||||
if (this.state.currentSrc === src && this.howl) {
|
||||
if (autoplay && !this.state.isPlaying) {
|
||||
this.play();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate loads - if already loading this URL, skip
|
||||
if (this.isLoading && this.state.currentSrc === src) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading guard immediately
|
||||
this.isLoading = true;
|
||||
|
||||
// Simple instant switch - no crossfade (crossfade caused duplicate playback bugs)
|
||||
// Just stop current track and load new one
|
||||
this.cleanup();
|
||||
|
||||
this.state.currentSrc = src;
|
||||
|
||||
// Detect if running in Android WebView (for graceful degradation)
|
||||
const isAndroidWebView = typeof navigator !== "undefined" &&
|
||||
/wv/.test(navigator.userAgent.toLowerCase()) &&
|
||||
/android/.test(navigator.userAgent.toLowerCase());
|
||||
this.shouldRetryLoads = isAndroidWebView;
|
||||
|
||||
// Check if this is a podcast/audiobook stream (they need HTML5 Audio for Range request support)
|
||||
const isPodcastOrAudiobook = src.includes("/api/podcasts/") || src.includes("/api/audiobooks/");
|
||||
|
||||
// Build Howl config
|
||||
// Note: On Android WebView, HTML5 Audio causes crackling/popping on track changes
|
||||
// Use Web Audio API on Android for smoother playback (trades streaming for quality)
|
||||
// EXCEPTION: Podcasts always use HTML5 Audio because they need Range request support
|
||||
// for seeking in large files. Web Audio would try to download the entire ~100MB file.
|
||||
const howlConfig: any = {
|
||||
src: [src],
|
||||
html5: isPodcastOrAudiobook || !isAndroidWebView, // HTML5 for podcasts/audiobooks OR non-Android
|
||||
autoplay: false, // We'll handle autoplay with fade
|
||||
preload: true,
|
||||
volume: this.state.isMuted ? 0 : this.state.volume,
|
||||
// On Android WebView, increase the xhr timeout
|
||||
...(isAndroidWebView && { xhr: { timeout: 30000 } }),
|
||||
};
|
||||
|
||||
// Store for potential retry
|
||||
this.pendingAutoplay = autoplay;
|
||||
this.lastFormat = format;
|
||||
// Reset retry count only when this is NOT a retry attempt.
|
||||
// If we reset on retries, we can end up in an infinite retry loop.
|
||||
if (!isRetry) {
|
||||
this.retryCount = 0;
|
||||
}
|
||||
|
||||
// Add format hints (required for URLs without file extensions)
|
||||
// Include multiple formats as fallbacks - browser will try them in order
|
||||
if (format) {
|
||||
// Put the expected format first, then common fallbacks
|
||||
const formats = [format];
|
||||
if (!formats.includes("mp3")) formats.push("mp3");
|
||||
if (!formats.includes("flac")) formats.push("flac");
|
||||
if (!formats.includes("mp4")) formats.push("mp4");
|
||||
if (!formats.includes("webm")) formats.push("webm");
|
||||
howlConfig.format = formats;
|
||||
} else {
|
||||
// Default format order if none specified
|
||||
howlConfig.format = ["mp3", "flac", "mp4", "webm", "wav"];
|
||||
}
|
||||
|
||||
this.howl = new Howl({
|
||||
...howlConfig,
|
||||
onload: () => {
|
||||
this.isLoading = false;
|
||||
this.state.duration = this.howl?.duration() || 0;
|
||||
this.emit("load", { duration: this.state.duration });
|
||||
|
||||
if (autoplay) {
|
||||
this.play();
|
||||
}
|
||||
},
|
||||
onloaderror: (id, error) => {
|
||||
console.error("[HowlerEngine] Load error:", error, "Attempt:", this.retryCount + 1);
|
||||
this.isLoading = false;
|
||||
|
||||
// Retry logic for transient errors (common on Android WebView)
|
||||
if (this.shouldRetryLoads && this.retryCount < this.maxRetries && this.state.currentSrc) {
|
||||
this.retryCount++;
|
||||
console.log(`[HowlerEngine] Retrying load (attempt ${this.retryCount}/${this.maxRetries})...`);
|
||||
|
||||
// Save src before cleanup
|
||||
const srcToRetry = this.state.currentSrc;
|
||||
const autoplayToRetry = this.pendingAutoplay;
|
||||
const formatToRetry = this.lastFormat;
|
||||
|
||||
// CRITICAL: Clean up the failed Howl instance BEFORE retrying
|
||||
// This prevents "HTML5 Audio pool exhausted" errors
|
||||
this.cleanup();
|
||||
|
||||
// Wait a bit before retrying
|
||||
setTimeout(() => {
|
||||
this.load(srcToRetry, autoplayToRetry, formatToRetry, true);
|
||||
}, 500 * this.retryCount); // Exponential backoff
|
||||
return;
|
||||
}
|
||||
|
||||
// All retries failed - clean up and emit error
|
||||
this.retryCount = 0;
|
||||
this.cleanup(); // Clean up failed instance
|
||||
this.emit("loaderror", { error });
|
||||
},
|
||||
onplayerror: (id, error) => {
|
||||
console.error("[HowlerEngine] Play error:", error);
|
||||
// Clear playing state so UI shows play button
|
||||
this.state.isPlaying = false;
|
||||
this.userInitiatedPlay = false;
|
||||
this.stopTimeUpdates();
|
||||
this.emit("playerror", { error });
|
||||
// Don't try to auto-recover - let the user click play again
|
||||
// The 'unlock' mechanism requires a NEW user interaction which won't happen automatically
|
||||
},
|
||||
onplay: () => {
|
||||
this.state.isPlaying = true;
|
||||
this.userInitiatedPlay = false; // Clear flag after successful play
|
||||
this.startTimeUpdates();
|
||||
this.emit("play");
|
||||
},
|
||||
onpause: () => {
|
||||
this.state.isPlaying = false;
|
||||
this.userInitiatedPlay = false;
|
||||
this.stopTimeUpdates();
|
||||
this.emit("pause");
|
||||
},
|
||||
onstop: () => {
|
||||
this.state.isPlaying = false;
|
||||
this.state.currentTime = 0;
|
||||
this.stopTimeUpdates();
|
||||
this.emit("stop");
|
||||
},
|
||||
onend: () => {
|
||||
this.state.isPlaying = false;
|
||||
this.stopTimeUpdates();
|
||||
this.emit("end");
|
||||
},
|
||||
onseek: () => {
|
||||
if (this.howl) {
|
||||
this.state.currentTime = this.howl.seek() as number;
|
||||
this.emit("seek", { time: this.state.currentTime });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play audio (user-initiated)
|
||||
*/
|
||||
play(): void {
|
||||
if (!this.howl) {
|
||||
console.warn("[HowlerEngine] No audio loaded");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't reset volume if already playing
|
||||
if (this.state.isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as user-initiated for autoplay recovery
|
||||
this.userInitiatedPlay = true;
|
||||
|
||||
// Ensure volume is set correctly before playing
|
||||
const targetVolume = this.state.isMuted ? 0 : this.state.volume;
|
||||
this.howl.volume(targetVolume);
|
||||
this.howl.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause audio
|
||||
*/
|
||||
pause(): void {
|
||||
if (!this.howl || !this.state.isPlaying) return;
|
||||
this.howl.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop playback completely
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.howl) return;
|
||||
this.howl.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a specific time
|
||||
* Simple seek - UI handles buffering state if needed
|
||||
*/
|
||||
seek(time: number): void {
|
||||
if (!this.howl) return;
|
||||
|
||||
this.state.currentTime = time;
|
||||
this.howl.seek(time);
|
||||
this.emit("seek", { time });
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reload the audio from current source
|
||||
* Used after cache is ready to enable seeking
|
||||
*/
|
||||
reload(): void {
|
||||
if (!this.state.currentSrc) return;
|
||||
|
||||
const src = this.state.currentSrc;
|
||||
const format = this.howl ? (this.howl as any)._format : undefined;
|
||||
|
||||
this.cleanup();
|
||||
this.load(src, false, format?.[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set volume (0-1)
|
||||
*/
|
||||
setVolume(volume: number): void {
|
||||
this.state.volume = Math.max(0, Math.min(1, volume));
|
||||
|
||||
if (this.howl && !this.state.isMuted) {
|
||||
this.howl.volume(this.state.volume);
|
||||
}
|
||||
|
||||
this.emit("volume", { volume: this.state.volume });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute/unmute
|
||||
*/
|
||||
setMuted(muted: boolean): void {
|
||||
this.state.isMuted = muted;
|
||||
|
||||
if (this.howl) {
|
||||
this.howl.volume(muted ? 0 : this.state.volume);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current playback state
|
||||
*/
|
||||
getState(): Readonly<HowlerEngineState> {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current time (from Howler's state)
|
||||
*/
|
||||
getCurrentTime(): number {
|
||||
if (this.howl) {
|
||||
const seek = this.howl.seek();
|
||||
return typeof seek === "number" ? seek : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ACTUAL current time from the HTML5 audio element
|
||||
* This is more accurate than Howler's reported position after failed seeks
|
||||
*/
|
||||
getActualCurrentTime(): number {
|
||||
if (!this.howl) return 0;
|
||||
|
||||
try {
|
||||
// Access the underlying HTML5 audio element
|
||||
const sounds = (this.howl as any)._sounds;
|
||||
if (sounds && sounds.length > 0 && sounds[0]._node) {
|
||||
return sounds[0]._node.currentTime || 0;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to Howler's reported time
|
||||
}
|
||||
|
||||
return this.getCurrentTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duration
|
||||
*/
|
||||
getDuration(): number {
|
||||
return this.howl?.duration() || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently playing
|
||||
*/
|
||||
isPlaying(): boolean {
|
||||
return this.howl?.playing() || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(event: HowlerEventType, callback: HowlerEventCallback): void {
|
||||
this.eventListeners.get(event)?.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from events
|
||||
*/
|
||||
off(event: HowlerEventType, callback: HowlerEventCallback): void {
|
||||
this.eventListeners.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to all listeners
|
||||
*/
|
||||
private emit(event: HowlerEventType, data?: any): void {
|
||||
this.eventListeners.get(event)?.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[HowlerEngine] Event listener error (${event}):`,
|
||||
err
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start time update interval
|
||||
*/
|
||||
private startTimeUpdates(): void {
|
||||
this.stopTimeUpdates();
|
||||
|
||||
this.timeUpdateInterval = setInterval(() => {
|
||||
if (this.howl && this.state.isPlaying) {
|
||||
const seek = this.howl.seek();
|
||||
if (typeof seek === "number") {
|
||||
this.state.currentTime = seek;
|
||||
this.emit("timeupdate", { time: seek });
|
||||
}
|
||||
}
|
||||
}, 250); // Update 4 times per second
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop time update interval
|
||||
*/
|
||||
private stopTimeUpdates(): void {
|
||||
if (this.timeUpdateInterval) {
|
||||
clearInterval(this.timeUpdateInterval);
|
||||
this.timeUpdateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup current Howl instance
|
||||
*/
|
||||
private cleanup(): void {
|
||||
this.stopTimeUpdates();
|
||||
|
||||
// Cancel any pending cleanup timeout to prevent race conditions
|
||||
if (this.cleanupTimeoutId) {
|
||||
clearTimeout(this.cleanupTimeoutId);
|
||||
this.cleanupTimeoutId = null;
|
||||
}
|
||||
|
||||
if (this.howl) {
|
||||
const oldHowl = this.howl;
|
||||
const wasPlaying = this.state.isPlaying;
|
||||
const targetVolume = this.state.isMuted ? 0 : this.state.volume;
|
||||
|
||||
// Detach immediately so new loads don't race with cleanup.
|
||||
this.howl = null;
|
||||
|
||||
try {
|
||||
if (wasPlaying) {
|
||||
// Micro-fade before stop/unload to reduce click/pop artifacts.
|
||||
oldHowl.fade(targetVolume, 0, this.popFadeMs);
|
||||
this.cleanupTimeoutId = setTimeout(() => {
|
||||
this.cleanupTimeoutId = null;
|
||||
try {
|
||||
oldHowl.stop();
|
||||
oldHowl.unload();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, this.popFadeMs + 2);
|
||||
} else {
|
||||
// Synchronous cleanup when not playing - no race condition risk
|
||||
oldHowl.stop();
|
||||
oldHowl.unload();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Removed Howler.unload() - it was unloading ALL audio globally
|
||||
// which caused issues. Individual howl.unload() calls are sufficient.
|
||||
|
||||
this.state.currentSrc = null;
|
||||
this.state.isPlaying = false;
|
||||
this.state.currentTime = 0;
|
||||
this.state.duration = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the engine completely
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cleanup();
|
||||
this.isLoading = false;
|
||||
this.eventListeners.clear();
|
||||
// Ensure cleanup timeout is cleared
|
||||
if (this.cleanupTimeoutId) {
|
||||
clearTimeout(this.cleanupTimeoutId);
|
||||
this.cleanupTimeoutId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const howlerEngine = new HowlerEngine();
|
||||
|
||||
// Also export class for testing
|
||||
export { HowlerEngine };
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Creates a new QueryClient instance with sensible defaults for the music streaming app
|
||||
*
|
||||
* Configuration rationale:
|
||||
* - staleTime: Time before data is considered stale and needs refetching
|
||||
* - cacheTime: Time to keep unused data in cache before garbage collection
|
||||
* - refetchOnWindowFocus: Disabled for music app - we don't want to interrupt playback
|
||||
* - retry: Number of times to retry failed requests
|
||||
*/
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Data freshness configuration
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - default for most data
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes - formerly called cacheTime
|
||||
|
||||
// Refetch behavior
|
||||
refetchOnWindowFocus: false, // Don't refetch on window focus (music app)
|
||||
refetchOnMount: true, // Refetch when component mounts if data is stale
|
||||
refetchOnReconnect: true, // Refetch when internet reconnects
|
||||
|
||||
// Retry configuration
|
||||
retry: 1, // Retry once on failure
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
|
||||
// Error handling
|
||||
throwOnError: false, // Don't throw errors, let components handle them
|
||||
},
|
||||
mutations: {
|
||||
// Mutations generally don't need retry for user actions
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Gets the QueryClient instance
|
||||
* For server-side rendering, always create a new instance
|
||||
* For client-side, reuse the same instance across renders
|
||||
*/
|
||||
function getQueryClient() {
|
||||
if (typeof window === "undefined") {
|
||||
// Server: always make a new query client
|
||||
return makeQueryClient();
|
||||
} else {
|
||||
// Browser: make a new query client if we don't already have one
|
||||
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
||||
return browserQueryClient;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryProvider component to wrap the app with React Query
|
||||
* Includes devtools in development mode
|
||||
*/
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
// NOTE: Avoid useState when initializing the query client if you don't
|
||||
// have a suspense boundary between this and the code that may suspend
|
||||
// because React will throw away the client on the initial render if it
|
||||
// suspends and there is no boundary
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{/* DevTools only in development */}
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the query client for use in server components or utilities
|
||||
*/
|
||||
export { getQueryClient };
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { CheckCircle2, XCircle, AlertCircle, Info, X } from "lucide-react";
|
||||
import { cn } from "@/utils/cn";
|
||||
|
||||
type ToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toast: {
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warning: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
|
||||
const addToast = useCallback((type: ToastType, message: string) => {
|
||||
// Use a more unique ID that combines timestamp, counter, and random value
|
||||
const id = `${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 9)}`;
|
||||
|
||||
setToasts((prev) => [...prev, { id, type, message }]);
|
||||
|
||||
// Auto-dismiss after 5 seconds and store timeout ID
|
||||
const timeoutId = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timeoutsRef.current.delete(id);
|
||||
}, 5000);
|
||||
|
||||
timeoutsRef.current.set(id, timeoutId);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
// Clear the timeout if toast is manually dismissed
|
||||
const timeoutId = timeoutsRef.current.get(id);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutsRef.current.delete(id);
|
||||
}
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
// Cleanup all timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timeoutsRef.current.forEach((timeoutId) => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
timeoutsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toast = {
|
||||
success: (message: string) => addToast("success", message),
|
||||
error: (message: string) => addToast("error", message),
|
||||
warning: (message: string) => addToast("warning", message),
|
||||
info: (message: string) => addToast("info", message),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full px-4 md:px-0">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem
|
||||
key={t.id}
|
||||
toast={t}
|
||||
onClose={() => removeToast(t.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
const icons = {
|
||||
success: CheckCircle2,
|
||||
error: XCircle,
|
||||
warning: AlertCircle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
const styles = {
|
||||
success:
|
||||
"bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-green-500/50 text-green-500",
|
||||
error: "bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-red-500/50 text-red-500",
|
||||
warning:
|
||||
"bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-yellow-500/50 text-yellow-500",
|
||||
info: "bg-gradient-to-br from-[#141414] to-[#0f0f0f] border-blue-500/50 text-blue-500",
|
||||
};
|
||||
|
||||
const Icon = icons[toast.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-sm border shadow-2xl animate-in slide-in-from-right duration-300",
|
||||
styles[toast.type]
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<p className="flex-1 text-sm text-white font-medium">
|
||||
{toast.message}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Android TV / D-pad Navigation Utilities
|
||||
* Provides keyboard navigation support for TV interfaces
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
|
||||
// Check if running on Android TV
|
||||
export function isAndroidTV(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
|
||||
// Check for leanback/TV user agent hints
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
const isTV = ua.includes("android tv") ||
|
||||
ua.includes("googletv") ||
|
||||
ua.includes("aftb") || // Fire TV
|
||||
ua.includes("aftt") || // Fire TV Stick
|
||||
ua.includes("afts") || // Fire TV
|
||||
ua.includes("aftm") || // Fire TV
|
||||
(ua.includes("android") && ua.includes("tv"));
|
||||
|
||||
// Also check for large screen with no touch (TV indicator)
|
||||
const isLargeScreen = window.innerWidth >= 1920;
|
||||
const noTouch = !('ontouchstart' in window);
|
||||
|
||||
// Check URL param for testing: ?tv=1
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tvParam = urlParams.get('tv') === '1';
|
||||
|
||||
return isTV || tvParam || (isLargeScreen && noTouch && ua.includes("android"));
|
||||
}
|
||||
|
||||
// React hook to detect Android TV (with SSR safety)
|
||||
export function useIsTV(): boolean {
|
||||
const [isTV, setIsTV] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTV(isAndroidTV());
|
||||
}, []);
|
||||
|
||||
return isTV;
|
||||
}
|
||||
|
||||
// D-pad key codes
|
||||
export const DPAD_KEYS = {
|
||||
UP: "ArrowUp",
|
||||
DOWN: "ArrowDown",
|
||||
LEFT: "ArrowLeft",
|
||||
RIGHT: "ArrowRight",
|
||||
CENTER: "Enter",
|
||||
BACK: "Escape",
|
||||
PLAY_PAUSE: "MediaPlayPause",
|
||||
FAST_FORWARD: "MediaFastForward",
|
||||
REWIND: "MediaRewind",
|
||||
STOP: "MediaStop",
|
||||
// Note: Volume keys (AudioVolumeUp, AudioVolumeDown, AudioVolumeMute)
|
||||
// are typically handled by the Android system, not the web app
|
||||
} as const;
|
||||
|
||||
// Hook to handle D-pad navigation with custom actions
|
||||
export function useDpadNavigation(options?: {
|
||||
onSelect?: () => void;
|
||||
onBack?: () => void;
|
||||
onPlayPause?: () => void;
|
||||
}) {
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case DPAD_KEYS.CENTER:
|
||||
options?.onSelect?.();
|
||||
break;
|
||||
case DPAD_KEYS.BACK:
|
||||
options?.onBack?.();
|
||||
break;
|
||||
case DPAD_KEYS.PLAY_PAUSE:
|
||||
e.preventDefault();
|
||||
options?.onPlayPause?.();
|
||||
break;
|
||||
}
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
|
||||
// Get focusable elements within a container
|
||||
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
const selector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="button"]',
|
||||
].join(', ');
|
||||
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(selector));
|
||||
}
|
||||
|
||||
// Focus management for grid navigation
|
||||
export function navigateGrid(
|
||||
currentElement: HTMLElement,
|
||||
direction: 'up' | 'down' | 'left' | 'right',
|
||||
container: HTMLElement
|
||||
): HTMLElement | null {
|
||||
const focusables = getFocusableElements(container);
|
||||
const currentIndex = focusables.indexOf(currentElement);
|
||||
|
||||
if (currentIndex === -1) return null;
|
||||
|
||||
// Simple grid navigation - assumes uniform grid layout
|
||||
const rect = currentElement.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Estimate items per row
|
||||
const itemsPerRow = Math.round(containerRect.width / rect.width);
|
||||
|
||||
let targetIndex: number;
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
targetIndex = Math.max(0, currentIndex - 1);
|
||||
break;
|
||||
case 'right':
|
||||
targetIndex = Math.min(focusables.length - 1, currentIndex + 1);
|
||||
break;
|
||||
case 'up':
|
||||
targetIndex = Math.max(0, currentIndex - itemsPerRow);
|
||||
break;
|
||||
case 'down':
|
||||
targetIndex = Math.min(focusables.length - 1, currentIndex + itemsPerRow);
|
||||
break;
|
||||
default:
|
||||
targetIndex = currentIndex;
|
||||
}
|
||||
|
||||
return focusables[targetIndex] || null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user