feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
16
.vscode/launch.json
vendored
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -258,6 +282,20 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
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:', {
|
||||||
src: audioCurrent.src,
|
src: audioCurrent.src,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
102
app/components/AudioSettingsDialog.tsx
Normal file
102
app/components/AudioSettingsDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
app/components/DraggableMiniPlayer.tsx
Normal file
274
app/components/DraggableMiniPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }}
|
||||||
@@ -912,6 +918,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
</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
|
||||||
className="w-24"
|
className="w-24"
|
||||||
@@ -987,5 +1001,10 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
<AudioSettingsDialog
|
||||||
|
isOpen={showAudioSettings}
|
||||||
|
onClose={() => setShowAudioSettings(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
169
lib/audio-effects.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
16
lib/utils.ts
16
lib/utils.ts
@@ -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]}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
43
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user