Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+1480
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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";
+874
View File
@@ -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;
}
+78
View File
@@ -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,
};
}
+121
View File
@@ -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;
}
+24
View File
@@ -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();
+737
View File
@@ -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;
}
+166
View File
@@ -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;
}
+159
View File
@@ -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;
}
+507
View File
@@ -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 };
+90
View File
@@ -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 };
+151
View File
@@ -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>
);
}
+141
View File
@@ -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;
}