feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component

This commit is contained in:
2025-08-10 02:57:55 +00:00
committed by GitHub
parent 192148adf2
commit cfd4f88b5e
14 changed files with 974 additions and 125 deletions

5
.gitignore vendored
View File

@@ -70,6 +70,11 @@ next-env.d.ts
# database # database
still-database/ still-database/
# Debug related files
scripts/sleep-debug.js
.vscode/launch.json
source-map-support/
.next/ .next/
certificates certificates
.vercel .vercel

16
.vscode/launch.json vendored
View File

@@ -1,6 +1,22 @@
{ {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"type": "node",
"request": "launch",
"name": "Merow",
"program": "${workspaceFolder}/scripts/sleep-debug.js",
"skipFiles": [
"<node_internals>/**"
],
"console": "integratedTerminal",
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/**",
"!**/node_modules/**"
],
"trace": true
},
{ {
"name": "Debug: Next.js Development", "name": "Debug: Next.js Development",
"type": "node", "type": "node",

View File

@@ -11,9 +11,25 @@ import { useToast } from '@/hooks/use-toast';
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler'; import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm'; import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { DraggableMiniPlayer } from './DraggableMiniPlayer';
export const AudioPlayer: React.FC = () => { export const AudioPlayer: React.FC = () => {
const { currentTrack, playPreviousTrack, addToQueue, playNextTrack, clearQueue, queue, toggleShuffle, shuffle, toggleCurrentTrackStar } = useAudioPlayer(); const {
currentTrack,
playPreviousTrack,
addToQueue,
playNextTrack,
clearQueue,
queue,
toggleShuffle,
shuffle,
toggleCurrentTrackStar,
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
audioEffects
} = useAudioPlayer();
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -116,16 +132,18 @@ export const AudioPlayer: React.FC = () => {
useEffect(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
// Load saved volume if (currentTrack) {
const savedVolume = localStorage.getItem('navidrome-volume'); // Load saved volume
if (savedVolume) { const savedVolume = localStorage.getItem('navidrome-volume');
try { if (savedVolume) {
const volumeValue = parseFloat(savedVolume); try {
if (volumeValue >= 0 && volumeValue <= 1) { const volumeValue = parseFloat(savedVolume);
setVolume(volumeValue); if (volumeValue >= 0 && volumeValue <= 1) {
setVolume(volumeValue);
}
} catch (error) {
console.error('Failed to parse saved volume:', error);
} }
} catch (error) {
console.error('Failed to parse saved volume:', error);
} }
} }
@@ -221,17 +239,22 @@ export const AudioPlayer: React.FC = () => {
} }
} }
keysToRemove.forEach(key => localStorage.removeItem(key)); keysToRemove.forEach(key => localStorage.removeItem(key));
}, [isMobile, audioInitialized, volume]); }, [isMobile, audioInitialized, volume, currentTrack]);
// Apply volume to audio element when volume changes // Apply volume to audio element when volume changes
useEffect(() => { useEffect(() => {
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
if (audioCurrent) { if (audioCurrent) {
audioCurrent.volume = volume; // Apply volume through audio effects chain if available
if (audioEffects) {
audioEffects.setVolume(volume);
} else {
audioCurrent.volume = volume;
}
} }
// Save volume to localStorage // Save volume to localStorage
localStorage.setItem('navidrome-volume', volume.toString()); localStorage.setItem('navidrome-volume', volume.toString());
}, [volume]); }, [volume, audioEffects]);
// Save position when component unmounts or track changes // Save position when component unmounts or track changes
useEffect(() => { useEffect(() => {
@@ -245,6 +268,7 @@ export const AudioPlayer: React.FC = () => {
useEffect(() => { useEffect(() => {
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
const preloadAudioCurrent = preloadAudioRef.current;
if (currentTrack && audioCurrent && audioCurrent.src !== currentTrack.url) { if (currentTrack && audioCurrent && audioCurrent.src !== currentTrack.url) {
// Always clear current track time when changing tracks // Always clear current track time when changing tracks
@@ -257,6 +281,20 @@ export const AudioPlayer: React.FC = () => {
console.error('❌ Invalid audio URL:', currentTrack.url); console.error('❌ Invalid audio URL:', currentTrack.url);
return; return;
} }
// If we have audio effects and ReplayGain is enabled, apply it
if (audioEffects && audioSettings.replayGainEnabled && currentTrack.replayGain) {
audioEffects.setReplayGain(currentTrack.replayGain);
}
// For gapless playback, start preloading the next track
if (audioSettings.gaplessPlayback && queue.length > 0) {
const nextTrack = queue[0];
if (preloadAudioCurrent && nextTrack) {
preloadAudioCurrent.src = nextTrack.url;
preloadAudioCurrent.load();
}
}
// Debug: Log current audio element state // Debug: Log current audio element state
console.log('🔍 Audio element state before loading:', { console.log('🔍 Audio element state before loading:', {
@@ -370,7 +408,7 @@ export const AudioPlayer: React.FC = () => {
setIsPlaying(false); setIsPlaying(false);
} }
} }
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]); }, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized, audioEffects, audioSettings.gaplessPlayback, audioSettings.replayGainEnabled, queue]);
useEffect(() => { useEffect(() => {
const audioCurrent = audioRef.current; const audioCurrent = audioRef.current;
@@ -399,6 +437,12 @@ export const AudioPlayer: React.FC = () => {
// Notify scrobbler about track end // Notify scrobbler about track end
onTrackEnd(currentTrack, audioCurrent.currentTime, audioCurrent.duration); onTrackEnd(currentTrack, audioCurrent.currentTime, audioCurrent.duration);
// If crossfade is enabled and we have more tracks in queue
if (audioSettings.crossfadeDuration > 0 && queue.length > 0 && audioEffects) {
// Start fading out current track
audioEffects.setCrossfadeTime(audioSettings.crossfadeDuration);
}
} }
playNextTrack(); playNextTrack();
}; };
@@ -442,49 +486,50 @@ export const AudioPlayer: React.FC = () => {
audioCurrent.removeEventListener('pause', handlePause); audioCurrent.removeEventListener('pause', handlePause);
} }
}; };
}, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause]); }, [playNextTrack, currentTrack, onTrackProgress, onTrackEnd, onTrackPlay, onTrackPause, audioEffects, audioSettings.crossfadeDuration, queue.length]);
// Update document title and optionally show a notification when a new song starts // Update document title and optionally show a notification when a new song starts
useEffect(() => { useEffect(() => {
if (!isClient) return; if (!isClient || !currentTrack) {
if (currentTrack) { if (!currentTrack) {
// Update favicon/title like Spotify document.title = 'mice';
const baseTitle = `${currentTrack.name}${currentTrack.artist} mice`;
document.title = isPlaying ? baseTitle : `(Paused) ${baseTitle}`;
// Notifications
const notifyEnabled = localStorage.getItem('playback-notifications-enabled') === 'true';
const canNotify = 'Notification' in window && Notification.permission !== 'denied';
if (notifyEnabled && canNotify && lastNotifiedTrackId !== currentTrack.id) {
try {
if (Notification.permission === 'default') {
Notification.requestPermission().then((perm) => {
if (perm === 'granted') {
new Notification('Now Playing', {
body: `${currentTrack.name}${currentTrack.artist}`,
icon: currentTrack.coverArt || '/icon-192.png',
badge: '/icon-192.png',
});
setLastNotifiedTrackId(currentTrack.id);
}
});
} else if (Notification.permission === 'granted') {
new Notification('Now Playing', {
body: `${currentTrack.name}${currentTrack.artist}`,
icon: currentTrack.coverArt || '/icon-192.png',
badge: '/icon-192.png',
});
setLastNotifiedTrackId(currentTrack.id);
}
} catch (e) {
console.warn('Notification failed:', e);
}
} }
} else { return;
// Reset title when no track
document.title = 'mice';
} }
}, [currentTrack?.id, currentTrack?.name, currentTrack?.artist, currentTrack?.coverArt, isPlaying, isClient, lastNotifiedTrackId]);
// Update favicon/title like Spotify
const baseTitle = `${currentTrack.name}${currentTrack.artist} mice`;
document.title = isPlaying ? baseTitle : `(Paused) ${baseTitle}`;
// Notifications
const notifyEnabled = localStorage.getItem('playback-notifications-enabled') === 'true';
const canNotify = 'Notification' in window && Notification.permission !== 'denied';
if (notifyEnabled && canNotify && lastNotifiedTrackId !== currentTrack.id) {
try {
if (Notification.permission === 'default') {
Notification.requestPermission().then((perm) => {
if (perm === 'granted') {
new Notification('Now Playing', {
body: `${currentTrack.name}${currentTrack.artist}`,
icon: currentTrack.coverArt || '/icon-192.png',
badge: '/icon-192.png',
});
setLastNotifiedTrackId(currentTrack.id);
}
});
} else if (Notification.permission === 'granted') {
new Notification('Now Playing', {
body: `${currentTrack.name}${currentTrack.artist}`,
icon: currentTrack.coverArt || '/icon-192.png',
badge: '/icon-192.png',
});
setLastNotifiedTrackId(currentTrack.id);
}
} catch (e) {
console.warn('Notification failed:', e);
}
}
}, [currentTrack, isPlaying, isClient, lastNotifiedTrackId]);
// Media Session API integration - Enhanced for mobile // Media Session API integration - Enhanced for mobile
useEffect(() => { useEffect(() => {
@@ -863,54 +908,7 @@ export const AudioPlayer: React.FC = () => {
if (isMinimized) { if (isMinimized) {
return ( return (
<> <>
<div className="fixed bottom-4 left-4 z-50"> <DraggableMiniPlayer onExpand={() => setIsMinimized(false)} />
<div
className="bg-background/95 backdrop-blur-xs border rounded-lg shadow-lg cursor-pointer hover:scale-[1.02] transition-transform w-80"
onClick={() => setIsMinimized(false)}
>
<div className="flex items-center p-3">
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
width={40}
height={40}
className="w-10 h-10 rounded-md shrink-0"
/>
<div className="flex-1 min-w-0 mx-3">
<div className="overflow-hidden">
<p className="font-semibold text-sm whitespace-nowrap animate-infinite-scroll">
{currentTrack.name}
</p>
</div>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
{/* Heart icon for favoriting */}
<button
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors mr-2"
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : 'text-gray-400'}`}
/>
</button>
<div className="flex items-center justify-center space-x-2">
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playPreviousTrack}>
<FaBackward className="w-3 h-3" />
</button>
<button className="p-2 hover:bg-gray-700/50 rounded-full transition-colors" onClick={togglePlayPause}>
{isPlaying ? <FaPause className="w-4 h-4" /> : <FaPlay className="w-4 h-4" />}
</button>
<button className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors" onClick={playNextTrack}>
<FaForward className="w-3 h-3" />
</button>
</div>
</div>
</div>
</div>
{/* Single audio element - shared across all UI states */} {/* Single audio element - shared across all UI states */}
<audio <audio

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Song, Album, Artist } from '@/lib/navidrome'; import { Song } from '@/lib/navidrome';
import { getNavidromeAPI } from '@/lib/navidrome'; import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { AudioEffects } from '@/lib/audio-effects';
export interface Track { export interface Track {
id: string; id: string;
@@ -15,8 +16,16 @@ export interface Track {
coverArt?: string; coverArt?: string;
albumId: string; albumId: string;
artistId: string; artistId: string;
autoPlay?: boolean; // Flag to control auto-play autoPlay?: boolean;
starred?: boolean; // Flag for starred/favorited tracks starred?: boolean;
replayGain?: number; // Added ReplayGain field
}
interface AudioSettings {
crossfadeDuration: number;
equalizer: string;
replayGainEnabled: boolean;
gaplessPlayback: boolean;
} }
interface AudioPlayerContextProps { interface AudioPlayerContextProps {
@@ -42,16 +51,37 @@ interface AudioPlayerContextProps {
clearHistory: () => void; clearHistory: () => void;
toggleCurrentTrackStar: () => Promise<void>; toggleCurrentTrackStar: () => Promise<void>;
updateTrackStarred: (trackId: string, starred: boolean) => void; updateTrackStarred: (trackId: string, starred: boolean) => void;
// Audio settings
audioSettings: AudioSettings;
updateAudioSettings: (settings: Partial<AudioSettings>) => void;
equalizerPreset: string;
setEqualizerPreset: (preset: string) => void;
audioEffects: AudioEffects | null;
// Playback state
isPlaying: boolean;
togglePlayPause: () => Promise<void>;
} }
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined); const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(undefined);
export // Default audio settings
const DEFAULT_AUDIO_SETTINGS: AudioSettings = {
crossfadeDuration: 3,
equalizer: 'normal',
replayGainEnabled: true,
gaplessPlayback: true
};
export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentTrack, setCurrentTrack] = useState<Track | null>(null); const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
const [queue, setQueue] = useState<Track[]>([]); const [queue, setQueue] = useState<Track[]>([]);
const [playedTracks, setPlayedTracks] = useState<Track[]>([]); const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [shuffle, setShuffle] = useState(false); const [shuffle, setShuffle] = useState(false);
const [audioSettings, setAudioSettings] = useState<AudioSettings>(DEFAULT_AUDIO_SETTINGS);
const [equalizerPreset, setEqualizerPreset] = useState('normal');
const [audioEffects, setAudioEffects] = useState<AudioEffects | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const api = useMemo(() => { const api = useMemo(() => {
const navidromeApi = getNavidromeAPI(); const navidromeApi = getNavidromeAPI();
@@ -102,6 +132,73 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
} }
}, [currentTrack]); }, [currentTrack]);
// Initialize audio effects when audio element is available
useEffect(() => {
const audioElement = audioRef.current;
if (audioElement && !audioEffects) {
const effects = new AudioEffects(audioElement);
setAudioEffects(effects);
// Load saved audio settings
const savedSettings = localStorage.getItem('navidrome-audio-settings');
if (savedSettings) {
try {
const settings = JSON.parse(savedSettings);
setAudioSettings(settings);
effects.setPreset(settings.equalizer);
setEqualizerPreset(settings.equalizer);
} catch (error) {
console.error('Failed to load audio settings:', error);
}
}
return () => {
effects.disconnect();
};
}
}, [audioEffects]);
// Save all audio-related settings
const saveSettings = useCallback(() => {
try {
// Save audio settings
localStorage.setItem('navidrome-audio-settings', JSON.stringify(audioSettings));
// Save equalizer preset
localStorage.setItem('navidrome-equalizer-preset', equalizerPreset);
// Save other playback settings
const playbackSettings = {
replayGainEnabled: audioSettings.replayGainEnabled,
gaplessPlayback: audioSettings.gaplessPlayback,
crossfadeDuration: audioSettings.crossfadeDuration,
volume: audioRef.current?.volume || 1,
lastPosition: audioRef.current?.currentTime || 0
};
localStorage.setItem('navidrome-playback-settings', JSON.stringify(playbackSettings));
} catch (error) {
console.error('Failed to save settings:', error);
}
}, [audioSettings, equalizerPreset]);
// Save settings whenever they change
useEffect(() => {
saveSettings();
}, [audioSettings, equalizerPreset, saveSettings]);
// Update equalizer when preset changes
useEffect(() => {
if (audioEffects) {
audioEffects.setPreset(equalizerPreset);
}
}, [equalizerPreset, audioEffects]);
const updateAudioSettings = useCallback((settings: Partial<AudioSettings>) => {
setAudioSettings(prev => {
const newSettings = { ...prev, ...settings };
localStorage.setItem('navidrome-audio-settings', JSON.stringify(newSettings));
return newSettings;
});
}, []);
const songToTrack = useMemo(() => (song: Song): Track => { const songToTrack = useMemo(() => (song: Song): Track => {
if (!api) { if (!api) {
throw new Error('Navidrome API not configured'); throw new Error('Navidrome API not configured');
@@ -120,7 +217,8 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 512) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred,
replayGain: song.replayGain || 0 // Add ReplayGain support
}; };
}, [api]); }, [api]);
@@ -573,6 +671,32 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
} }
} }
}, []); }, []);
// Track playback state
const [isPlaying, setIsPlaying] = useState(false);
// Shared playback control function
const togglePlayPause = useCallback(async () => {
const audioElement = audioRef.current;
if (!audioElement || !currentTrack) return;
try {
if (isPlaying) {
audioElement.pause();
setIsPlaying(false);
} else {
await audioElement.play();
setIsPlaying(true);
}
} catch (error) {
console.error('Failed to toggle playback:', error);
toast({
variant: "destructive",
title: "Playback Error",
description: "Failed to control playback. Please try again.",
});
}
}, [currentTrack, isPlaying, toast]);
const contextValue = useMemo(() => ({ const contextValue = useMemo(() => ({
currentTrack, currentTrack,
playTrack, playTrack,
@@ -594,6 +718,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playArtist, playArtist,
playedTracks, playedTracks,
clearHistory, clearHistory,
// Audio settings
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
audioEffects,
// Playback state
isPlaying,
togglePlayPause,
toggleCurrentTrackStar: async () => { toggleCurrentTrackStar: async () => {
if (!currentTrack || !api) { if (!currentTrack || !api) {
toast({ toast({
@@ -684,7 +817,14 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
playedTracks, playedTracks,
clearHistory, clearHistory,
api, api,
toast toast,
audioEffects,
audioSettings,
equalizerPreset,
updateAudioSettings,
setEqualizerPreset,
isPlaying,
togglePlayPause
]); ]);
return ( return (

View File

@@ -0,0 +1,102 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useAudioPlayer } from "./AudioPlayerContext";
import { presets } from "@/lib/audio-effects";
interface AudioSettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function AudioSettingsDialog({ isOpen, onClose }: AudioSettingsDialogProps) {
const {
audioSettings,
updateAudioSettings,
equalizerPreset,
setEqualizerPreset,
} = useAudioPlayer();
const handleCrossfadeChange = (value: number[]) => {
updateAudioSettings({ crossfadeDuration: value[0] });
};
const handleReplayGainToggle = (enabled: boolean) => {
updateAudioSettings({ replayGainEnabled: enabled });
};
const handleGaplessToggle = (enabled: boolean) => {
updateAudioSettings({ gaplessPlayback: enabled });
};
const handleEqualizerPresetChange = (preset: string) => {
setEqualizerPreset(preset);
updateAudioSettings({ equalizer: preset });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>
Configure playback settings and audio effects
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label>Crossfade Duration ({audioSettings.crossfadeDuration}s)</Label>
<Slider
value={[audioSettings.crossfadeDuration]}
onValueChange={handleCrossfadeChange}
min={0}
max={5}
step={0.5}
/>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<Label>ReplayGain</Label>
<Switch
checked={audioSettings.replayGainEnabled}
onCheckedChange={handleReplayGainToggle}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<Label>Gapless Playback</Label>
<Switch
checked={audioSettings.gaplessPlayback}
onCheckedChange={handleGaplessToggle}
/>
</div>
{/* Equalizer Presets */}
<div className="space-y-2">
<Label>Equalizer Preset</Label>
<div className="grid grid-cols-2 gap-2">
{Object.keys(presets).map((preset) => (
<Button
key={preset}
variant={preset === equalizerPreset ? "default" : "outline"}
onClick={() => handleEqualizerPresetChange(preset)}
className="w-full"
>
{presets[preset].name}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,274 @@
'use client';
import React, { useRef, useState, useEffect } from 'react';
import Image from 'next/image';
import { motion, PanInfo, AnimatePresence } from 'framer-motion';
import { useAudioPlayer, Track } from './AudioPlayerContext';
import { FaPlay, FaPause, FaExpand, FaForward, FaBackward } from 'react-icons/fa6';
import { Heart } from 'lucide-react';
import { constrain } from '@/lib/utils';
interface DraggableMiniPlayerProps {
onExpand: () => void;
}
export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpand }) => {
const {
currentTrack,
playPreviousTrack,
playNextTrack,
toggleCurrentTrackStar,
isPlaying,
togglePlayPause
} = useAudioPlayer();
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({ x: 0, y: 0 });
// Save position to localStorage when it changes
useEffect(() => {
if (!isDragging) {
localStorage.setItem('mini-player-position', JSON.stringify(position));
}
}, [position, isDragging]);
// Keyboard controls for the mini player
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle keyboard shortcuts if the mini player is focused
if (document.activeElement?.tagName === 'INPUT') return;
const step = e.shiftKey ? 100 : 10; // Larger steps with shift key
switch (e.key) {
case 'ArrowLeft':
setPosition(prev => ({
...prev,
x: constrain(
prev.x - step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowRight':
setPosition(prev => ({
...prev,
x: constrain(
prev.x + step,
-(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 + 16,
(window.innerWidth - (containerRef.current?.offsetWidth || 0)) / 2 - 16
)
}));
break;
case 'ArrowUp':
setPosition(prev => ({
...prev,
y: constrain(
prev.y - step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'ArrowDown':
setPosition(prev => ({
...prev,
y: constrain(
prev.y + step,
-(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 + 16,
(window.innerHeight - (containerRef.current?.offsetHeight || 0)) / 2 - 16
)
}));
break;
case 'Escape':
onExpand();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onExpand]);
// Load saved position on mount
useEffect(() => {
const savedPosition = localStorage.getItem('mini-player-position');
if (savedPosition) {
try {
const pos = JSON.parse(savedPosition);
setPosition(pos);
} catch (error) {
console.error('Failed to parse saved mini player position:', error);
}
}
}, []);
// Ensure player stays within viewport bounds
useEffect(() => {
const constrainToViewport = () => {
if (!containerRef.current || isDragging) return;
const rect = containerRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Add some padding from edges
const padding = 16;
const newX = constrain(
position.x,
-(viewportWidth - rect.width) / 2 + padding,
(viewportWidth - rect.width) / 2 - padding
);
const newY = constrain(
position.y,
-(viewportHeight - rect.height) / 2 + padding,
(viewportHeight - rect.height) / 2 - padding
);
if (newX !== position.x || newY !== position.y) {
setPosition({ x: newX, y: newY });
}
};
constrainToViewport();
window.addEventListener('resize', constrainToViewport);
return () => window.removeEventListener('resize', constrainToViewport);
}, [position, isDragging]);
const handleDragStart = () => {
setIsDragging(true);
dragStartRef.current = position;
};
const handleDrag = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
setPosition({
x: dragStartRef.current.x + info.offset.x,
y: dragStartRef.current.y + info.offset.y
});
};
const handleDragEnd = () => {
setIsDragging(false);
};
if (!currentTrack) return null;
return (
<AnimatePresence>
<motion.div
ref={containerRef}
drag
dragMomentum={false}
dragElastic={0}
onDragStart={handleDragStart}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
animate={{
x: position.x + window.innerWidth / 2,
y: position.y + window.innerHeight / 2,
scale: isDragging ? 1.02 : 1,
opacity: isDragging ? 0.8 : 1
}}
transition={{ type: 'spring', damping: 20 }}
style={{
position: 'fixed',
zIndex: 100,
transform: `translate(-50%, -50%)`
}}
className="cursor-grab active:cursor-grabbing"
>
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-xl hover:shadow-2xl transition-shadow p-3 w-[280px]">
<div className="flex items-center gap-3">
{/* Album Art */}
<div className="relative w-12 h-12 shrink-0">
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
fill
className="rounded object-cover"
/>
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">{currentTrack.name}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
</div>
{/* Controls */}
{/* Keyboard shortcut hint */}
<div className="text-xs text-muted-foreground text-center mt-2 px-2">
Arrow keys to move Hold Shift for larger steps Esc to expand
</div>
<div className="flex items-center justify-between mt-2 px-2">
<button
onClick={(e) => {
e.stopPropagation();
toggleCurrentTrackStar();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title={currentTrack.starred ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={`w-4 h-4 ${currentTrack.starred ? 'text-primary fill-primary' : ''}`}
/>
</button>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
playPreviousTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaBackward className="w-3 h-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePlayPause();
}}
className="p-3 hover:bg-muted/50 rounded-full transition-colors"
>
{isPlaying ? (
<FaPause className="w-4 h-4" />
) : (
<FaPlay className="w-4 h-4" />
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
playNextTrack();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaForward className="w-3 h-3" />
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
>
<FaExpand className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
</AnimatePresence>
);
};

View File

@@ -9,6 +9,7 @@ import { Progress } from '@/components/ui/progress';
import { lrcLibClient } from '@/lib/lrclib'; import { lrcLibClient } from '@/lib/lrclib';
import Link from 'next/link'; import Link from 'next/link';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { AudioSettingsDialog } from './AudioSettingsDialog';
import { import {
FaPlay, FaPlay,
FaPause, FaPause,
@@ -20,7 +21,8 @@ import {
FaRepeat, FaRepeat,
FaXmark, FaXmark,
FaQuoteLeft, FaQuoteLeft,
FaListUl FaListUl,
FaSliders
} from "react-icons/fa6"; } from "react-icons/fa6";
import { Heart } from 'lucide-react'; import { Heart } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -46,8 +48,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
shuffle, shuffle,
toggleShuffle, toggleShuffle,
toggleCurrentTrackStar, toggleCurrentTrackStar,
queue queue,
audioSettings,
updateAudioSettings
} = useAudioPlayer(); } = useAudioPlayer();
const [showAudioSettings, setShowAudioSettings] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const router = useRouter(); const router = useRouter();
@@ -441,8 +446,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
if (!currentTrack) return null; if (!currentTrack) return null;
return ( return (
<AnimatePresence> <>
{isOpen && ( <AnimatePresence>
{isOpen && (
<motion.div <motion.div
className="fixed inset-0 z-[70] bg-black overflow-hidden" className="fixed inset-0 z-[70] bg-black overflow-hidden"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -911,6 +917,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
<FaQuoteLeft className="w-5 h-5" /> <FaQuoteLeft className="w-5 h-5" />
</button> </button>
)} )}
<button
onClick={() => setShowAudioSettings(true)}
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors"
title="Audio Settings"
>
<FaSliders className="w-5 h-5" />
</button>
{showVolumeSlider && ( {showVolumeSlider && (
<div <div
@@ -987,5 +1001,10 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<AudioSettingsDialog
isOpen={showAudioSettings}
onClose={() => setShowAudioSettings(false)}
/>
</>
); );
}; };

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTheme } from '@/app/components/ThemeProvider'; import { useTheme } from '@/app/components/ThemeProvider';
@@ -25,6 +26,7 @@ const SettingsPage = () => {
const { toast } = useToast(); const { toast } = useToast();
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm(); const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
const { shortcutType, updateShortcutType } = useSidebarShortcuts(); const { shortcutType, updateShortcutType } = useSidebarShortcuts();
const audioPlayer = useAudioPlayer();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
serverUrl: '', serverUrl: '',
@@ -835,6 +837,87 @@ const SettingsPage = () => {
</Card> </Card>
{/* Theme Preview */} {/* Theme Preview */}
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaCog className="w-5 h-5" />
Audio Settings
</CardTitle>
<CardDescription>
Configure playback and audio effects
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Crossfade */}
<div className="space-y-2">
<Label htmlFor="crossfade-duration">Crossfade Duration</Label>
<Select
value={String(audioPlayer.audioSettings.crossfadeDuration)}
onValueChange={(value) => audioPlayer.updateAudioSettings({ crossfadeDuration: Number(value) })}
>
<SelectTrigger id="crossfade-duration">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">Off</SelectItem>
<SelectItem value="2">2 seconds</SelectItem>
<SelectItem value="3">3 seconds</SelectItem>
<SelectItem value="4">4 seconds</SelectItem>
<SelectItem value="5">5 seconds</SelectItem>
</SelectContent>
</Select>
</div>
{/* Equalizer Preset */}
<div className="space-y-2">
<Label htmlFor="equalizer-preset">Equalizer Preset</Label>
<Select
value={audioPlayer.equalizerPreset}
onValueChange={audioPlayer.setEqualizerPreset}
>
<SelectTrigger id="equalizer-preset">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bassBoost">Bass Boost</SelectItem>
<SelectItem value="trebleBoost">Treble Boost</SelectItem>
<SelectItem value="vocalBoost">Vocal Boost</SelectItem>
</SelectContent>
</Select>
</div>
{/* ReplayGain */}
<div className="flex items-center justify-between">
<div>
<Label>ReplayGain</Label>
<p className="text-sm text-muted-foreground">Normalize volume across tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.replayGainEnabled}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ replayGainEnabled: checked })}
/>
</div>
{/* Gapless Playback */}
<div className="flex items-center justify-between">
<div>
<Label>Gapless Playback</Label>
<p className="text-sm text-muted-foreground">Seamless transitions between tracks</p>
</div>
<Switch
checked={audioPlayer.audioSettings.gaplessPlayback}
onCheckedChange={(checked) => audioPlayer.updateAudioSettings({ gaplessPlayback: checked })}
/>
</div> <div className="text-sm text-muted-foreground space-y-2">
<p><strong>Crossfade:</strong> Smooth fade between tracks (2-5 seconds)</p>
<p><strong>Equalizer:</strong> Preset frequency adjustments for different music styles</p>
<p><strong>ReplayGain:</strong> Consistent volume across all tracks in your library</p>
<p><strong>Gapless:</strong> Perfect for live albums and continuous DJ mixes</p>
</div>
</CardContent>
</Card>
<Card className="mb-6 break-inside-avoid py-5"> <Card className="mb-6 break-inside-avoid py-5">
<CardHeader> <CardHeader>
<CardTitle>Preview</CardTitle> <CardTitle>Preview</CardTitle>

View File

@@ -286,9 +286,9 @@ class DownloadManager {
quality === 'medium' ? 192 : quality === 'medium' ? 192 :
quality === 'low' ? 128 : undefined; quality === 'low' ? 128 : undefined;
const format = quality === 'low' ? 'mp3' : undefined; // Use mp3 for low quality, original otherwise // Note: format parameter is not supported by the Navidrome API
// The server will automatically transcode based on maxBitRate
return api.getStreamUrl(songId, { maxBitRate, format }); return api.getStreamUrl(songId, maxBitRate);
} }
// LocalStorage fallback for browsers without service worker support // LocalStorage fallback for browsers without service worker support

169
lib/audio-effects.ts Normal file
View File

@@ -0,0 +1,169 @@
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
export interface AudioEffectPreset {
name: string;
gains: number[]; // Gains for different frequency bands
frequencies: number[]; // Center frequencies for each band
}
export const presets: { [key: string]: AudioEffectPreset } = {
normal: {
name: "Normal",
gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
bassBoost: {
name: "Bass Boost",
gains: [7, 5, 3, 2, 0, 0, 0, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
trebleBoost: {
name: "Treble Boost",
gains: [0, 0, 0, 0, 0, 0, 2, 3, 5, 7],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
},
vocalBoost: {
name: "Vocal Boost",
gains: [0, 0, 0, 2, 4, 4, 2, 0, 0, 0],
frequencies: [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]
}
};
export class AudioEffects {
private context: AudioContext;
private source: MediaElementAudioSourceNode | null = null;
private destination: AudioDestinationNode;
private filters: BiquadFilterNode[] = [];
private gainNode: GainNode;
private crossfadeGainNode: GainNode;
private analyser: AnalyserNode;
private replayGainNode: GainNode;
private currentPreset: string = 'normal';
constructor(audioElement: HTMLAudioElement) {
// Properly type the AudioContext initialization
this.context = new (window.AudioContext || window.webkitAudioContext || AudioContext)();
this.destination = this.context.destination;
this.gainNode = this.context.createGain();
this.crossfadeGainNode = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.replayGainNode = this.context.createGain();
// Initialize ReplayGain node
this.replayGainNode.gain.value = 1.0;
// Create the audio processing chain
this.setupAudioChain(audioElement);
// Initialize EQ filters
this.setupEqualizer();
}
private setupAudioChain(audioElement: HTMLAudioElement) {
// Disconnect any existing source
if (this.source) {
this.source.disconnect();
}
// Create new source from audio element
this.source = this.context.createMediaElementSource(audioElement);
// Connect the audio processing chain
this.source
.connect(this.replayGainNode)
.connect(this.gainNode)
.connect(this.crossfadeGainNode);
// Connect filters in series
let lastNode: AudioNode = this.crossfadeGainNode;
this.filters.forEach(filter => {
lastNode.connect(filter);
lastNode = filter;
});
// Connect to analyser and destination
lastNode.connect(this.analyser);
this.analyser.connect(this.destination);
}
private setupEqualizer() {
// Create 10-band EQ
presets.normal.frequencies.forEach((freq, index) => {
const filter = this.context.createBiquadFilter();
filter.type = 'peaking';
filter.frequency.value = freq;
filter.Q.value = 1.0;
filter.gain.value = 0;
this.filters.push(filter);
});
}
public setPreset(presetName: string) {
if (presets[presetName]) {
this.currentPreset = presetName;
presets[presetName].gains.forEach((gain, index) => {
if (this.filters[index]) {
this.filters[index].gain.setValueAtTime(gain, this.context.currentTime);
}
});
}
}
public getCurrentPreset(): string {
return this.currentPreset;
}
public setVolume(volume: number) {
if (this.gainNode) {
this.gainNode.gain.setValueAtTime(volume, this.context.currentTime);
}
}
public setCrossfadeTime(seconds: number) {
if (this.crossfadeGainNode) {
const now = this.context.currentTime;
this.crossfadeGainNode.gain.setValueAtTime(1, now);
this.crossfadeGainNode.gain.linearRampToValueAtTime(0, now + seconds);
}
}
public startCrossfade() {
if (this.crossfadeGainNode) {
this.crossfadeGainNode.gain.value = 1;
}
}
public setReplayGain(gain: number) {
if (this.replayGainNode) {
// Clamp gain between -12dB and +12dB for safety
const clampedGain = Math.max(-12, Math.min(12, gain));
const gainValue = Math.pow(10, clampedGain / 20); // Convert dB to linear gain
this.replayGainNode.gain.setValueAtTime(gainValue, this.context.currentTime);
}
}
public getAnalyserNode(): AnalyserNode {
return this.analyser;
}
public async resume() {
if (this.context.state === 'suspended') {
await this.context.resume();
}
}
public disconnect() {
if (this.source) {
this.source.disconnect();
}
this.filters.forEach(filter => filter.disconnect());
this.gainNode.disconnect();
this.crossfadeGainNode.disconnect();
this.analyser.disconnect();
this.replayGainNode.disconnect();
}
}

View File

@@ -68,6 +68,7 @@ export interface Song {
artistId: string; artistId: string;
type: string; type: string;
starred?: string; starred?: string;
replayGain?: number;
} }
export interface Playlist { export interface Playlist {

View File

@@ -4,3 +4,19 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function constrain(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function formatBytes(bytes: number, decimals: number = 2): string {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

View File

@@ -79,18 +79,21 @@
"eslint": "^9.32", "eslint": "^9.32",
"eslint-config-next": "15.4.5", "eslint-config-next": "15.4.5",
"postcss": "^8", "postcss": "^8",
"source-map-support": "^0.5.21",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"typescript": "^5" "typescript": "^5"
}, },
"packageManager": "pnpm@10.13.1", "packageManager": "pnpm@10.13.1",
"overrides": { "overrides": {
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6" "@types/react-dom": "19.1.6",
"typescript": "5.9.2"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6" "@types/react-dom": "19.1.6",
"typescript": "5.9.2"
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"sharp", "sharp",

43
pnpm-lock.yaml generated
View File

@@ -7,6 +7,7 @@ settings:
overrides: overrides:
'@types/react': 19.1.8 '@types/react': 19.1.8
'@types/react-dom': 19.1.6 '@types/react-dom': 19.1.6
typescript: 5.9.2
importers: importers:
@@ -214,11 +215,14 @@ importers:
postcss: postcss:
specifier: ^8 specifier: ^8
version: 8.5.6 version: 8.5.6
source-map-support:
specifier: ^0.5.21
version: 0.5.21
tailwindcss: tailwindcss:
specifier: ^4.1.11 specifier: ^4.1.11
version: 4.1.11 version: 4.1.11
typescript: typescript:
specifier: ^5 specifier: 5.9.2
version: 5.9.2 version: 5.9.2
packages: packages:
@@ -1622,20 +1626,20 @@ packages:
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.38.0 '@typescript-eslint/parser': ^8.38.0
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/parser@8.38.0': '@typescript-eslint/parser@8.38.0':
resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==} resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/project-service@8.38.0': '@typescript-eslint/project-service@8.38.0':
resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/scope-manager@8.38.0': '@typescript-eslint/scope-manager@8.38.0':
resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==}
@@ -1645,14 +1649,14 @@ packages:
resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/type-utils@8.38.0': '@typescript-eslint/type-utils@8.38.0':
resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/types@8.38.0': '@typescript-eslint/types@8.38.0':
resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==}
@@ -1662,14 +1666,14 @@ packages:
resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/utils@8.38.0': '@typescript-eslint/utils@8.38.0':
resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.9.0' typescript: 5.9.2
'@typescript-eslint/visitor-keys@8.38.0': '@typescript-eslint/visitor-keys@8.38.0':
resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==}
@@ -1868,6 +1872,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2130,7 +2137,7 @@ packages:
resolution: {integrity: sha512-IMijiXaZ43qFB+Gcpnb374ipTKD8JIyVNR+6VsifFQ/LHyx+A9wgcgSIhCX5PYSjwOoSYD5LtNHKlM5uc23eww==} resolution: {integrity: sha512-IMijiXaZ43qFB+Gcpnb374ipTKD8JIyVNR+6VsifFQ/LHyx+A9wgcgSIhCX5PYSjwOoSYD5LtNHKlM5uc23eww==}
peerDependencies: peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1' typescript: 5.9.2
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@@ -3146,6 +3153,13 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -3250,7 +3264,7 @@ packages:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: 5.9.2
tsconfig-paths@3.15.0: tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -4959,6 +4973,8 @@ snapshots:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
buffer-from@1.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -6417,6 +6433,13 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0: