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
|
||||
still-database/
|
||||
|
||||
# Debug related files
|
||||
scripts/sleep-debug.js
|
||||
.vscode/launch.json
|
||||
source-map-support/
|
||||
|
||||
.next/
|
||||
certificates
|
||||
.vercel
|
||||
|
||||
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"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",
|
||||
"type": "node",
|
||||
|
||||
@@ -11,9 +11,25 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import { useLastFmScrobbler } from '@/hooks/use-lastfm-scrobbler';
|
||||
import { useStandaloneLastFm } from '@/hooks/use-standalone-lastfm';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { DraggableMiniPlayer } from './DraggableMiniPlayer';
|
||||
|
||||
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 isMobile = useIsMobile();
|
||||
|
||||
@@ -116,16 +132,18 @@ export const AudioPlayer: React.FC = () => {
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
|
||||
// Load saved volume
|
||||
const savedVolume = localStorage.getItem('navidrome-volume');
|
||||
if (savedVolume) {
|
||||
try {
|
||||
const volumeValue = parseFloat(savedVolume);
|
||||
if (volumeValue >= 0 && volumeValue <= 1) {
|
||||
setVolume(volumeValue);
|
||||
if (currentTrack) {
|
||||
// Load saved volume
|
||||
const savedVolume = localStorage.getItem('navidrome-volume');
|
||||
if (savedVolume) {
|
||||
try {
|
||||
const volumeValue = parseFloat(savedVolume);
|
||||
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));
|
||||
}, [isMobile, audioInitialized, volume]);
|
||||
}, [isMobile, audioInitialized, volume, currentTrack]);
|
||||
|
||||
// Apply volume to audio element when volume changes
|
||||
useEffect(() => {
|
||||
const audioCurrent = audioRef.current;
|
||||
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
|
||||
localStorage.setItem('navidrome-volume', volume.toString());
|
||||
}, [volume]);
|
||||
}, [volume, audioEffects]);
|
||||
|
||||
// Save position when component unmounts or track changes
|
||||
useEffect(() => {
|
||||
@@ -245,6 +268,7 @@ export const AudioPlayer: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const audioCurrent = audioRef.current;
|
||||
const preloadAudioCurrent = preloadAudioRef.current;
|
||||
|
||||
if (currentTrack && audioCurrent && audioCurrent.src !== currentTrack.url) {
|
||||
// Always clear current track time when changing tracks
|
||||
@@ -257,6 +281,20 @@ export const AudioPlayer: React.FC = () => {
|
||||
console.error('❌ Invalid audio URL:', currentTrack.url);
|
||||
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
|
||||
console.log('🔍 Audio element state before loading:', {
|
||||
@@ -370,7 +408,7 @@ export const AudioPlayer: React.FC = () => {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}
|
||||
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized]);
|
||||
}, [currentTrack, onTrackStart, onTrackPlay, isMobile, audioInitialized, audioEffects, audioSettings.gaplessPlayback, audioSettings.replayGainEnabled, queue]);
|
||||
|
||||
useEffect(() => {
|
||||
const audioCurrent = audioRef.current;
|
||||
@@ -399,6 +437,12 @@ export const AudioPlayer: React.FC = () => {
|
||||
|
||||
// Notify scrobbler about track end
|
||||
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();
|
||||
};
|
||||
@@ -442,49 +486,50 @@ export const AudioPlayer: React.FC = () => {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!isClient) return;
|
||||
if (currentTrack) {
|
||||
// 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);
|
||||
}
|
||||
if (!isClient || !currentTrack) {
|
||||
if (!currentTrack) {
|
||||
document.title = 'mice';
|
||||
}
|
||||
} else {
|
||||
// Reset title when no track
|
||||
document.title = 'mice';
|
||||
return;
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
@@ -863,54 +908,7 @@ export const AudioPlayer: React.FC = () => {
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-4 left-4 z-50">
|
||||
<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>
|
||||
<DraggableMiniPlayer onExpand={() => setIsMinimized(false)} />
|
||||
|
||||
{/* Single audio element - shared across all UI states */}
|
||||
<audio
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Song, Album, Artist } from '@/lib/navidrome';
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Song } from '@/lib/navidrome';
|
||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { AudioEffects } from '@/lib/audio-effects';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
@@ -15,8 +16,16 @@ export interface Track {
|
||||
coverArt?: string;
|
||||
albumId: string;
|
||||
artistId: string;
|
||||
autoPlay?: boolean; // Flag to control auto-play
|
||||
starred?: boolean; // Flag for starred/favorited tracks
|
||||
autoPlay?: boolean;
|
||||
starred?: boolean;
|
||||
replayGain?: number; // Added ReplayGain field
|
||||
}
|
||||
|
||||
interface AudioSettings {
|
||||
crossfadeDuration: number;
|
||||
equalizer: string;
|
||||
replayGainEnabled: boolean;
|
||||
gaplessPlayback: boolean;
|
||||
}
|
||||
|
||||
interface AudioPlayerContextProps {
|
||||
@@ -42,16 +51,37 @@ interface AudioPlayerContextProps {
|
||||
clearHistory: () => void;
|
||||
toggleCurrentTrackStar: () => Promise<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);
|
||||
|
||||
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 }) => {
|
||||
const [currentTrack, setCurrentTrack] = useState<Track | null>(null);
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
const [playedTracks, setPlayedTracks] = useState<Track[]>([]);
|
||||
const [isLoading, setIsLoading] = 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 api = useMemo(() => {
|
||||
const navidromeApi = getNavidromeAPI();
|
||||
@@ -102,6 +132,73 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
}
|
||||
}, [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 => {
|
||||
if (!api) {
|
||||
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,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
starred: !!song.starred
|
||||
starred: !!song.starred,
|
||||
replayGain: song.replayGain || 0 // Add ReplayGain support
|
||||
};
|
||||
}, [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(() => ({
|
||||
currentTrack,
|
||||
playTrack,
|
||||
@@ -594,6 +718,15 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
playArtist,
|
||||
playedTracks,
|
||||
clearHistory,
|
||||
// Audio settings
|
||||
audioSettings,
|
||||
updateAudioSettings,
|
||||
equalizerPreset,
|
||||
setEqualizerPreset,
|
||||
audioEffects,
|
||||
// Playback state
|
||||
isPlaying,
|
||||
togglePlayPause,
|
||||
toggleCurrentTrackStar: async () => {
|
||||
if (!currentTrack || !api) {
|
||||
toast({
|
||||
@@ -684,7 +817,14 @@ export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
playedTracks,
|
||||
clearHistory,
|
||||
api,
|
||||
toast
|
||||
toast,
|
||||
audioEffects,
|
||||
audioSettings,
|
||||
equalizerPreset,
|
||||
updateAudioSettings,
|
||||
setEqualizerPreset,
|
||||
isPlaying,
|
||||
togglePlayPause
|
||||
]);
|
||||
|
||||
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 Link from 'next/link';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { AudioSettingsDialog } from './AudioSettingsDialog';
|
||||
import {
|
||||
FaPlay,
|
||||
FaPause,
|
||||
@@ -20,7 +21,8 @@ import {
|
||||
FaRepeat,
|
||||
FaXmark,
|
||||
FaQuoteLeft,
|
||||
FaListUl
|
||||
FaListUl,
|
||||
FaSliders
|
||||
} from "react-icons/fa6";
|
||||
import { Heart } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -46,8 +48,11 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
shuffle,
|
||||
toggleShuffle,
|
||||
toggleCurrentTrackStar,
|
||||
queue
|
||||
queue,
|
||||
audioSettings,
|
||||
updateAudioSettings
|
||||
} = useAudioPlayer();
|
||||
const [showAudioSettings, setShowAudioSettings] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const router = useRouter();
|
||||
@@ -441,8 +446,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
if (!currentTrack) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[70] bg-black overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -911,6 +917,14 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
<FaQuoteLeft className="w-5 h-5" />
|
||||
</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 && (
|
||||
<div
|
||||
@@ -987,5 +1001,10 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AudioSettingsDialog
|
||||
isOpen={showAudioSettings}
|
||||
onClose={() => setShowAudioSettings(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/app/components/ThemeProvider';
|
||||
@@ -25,6 +26,7 @@ const SettingsPage = () => {
|
||||
const { toast } = useToast();
|
||||
const { isEnabled: isStandaloneLastFmEnabled, getCredentials, getAuthUrl, getSessionKey } = useStandaloneLastFm();
|
||||
const { shortcutType, updateShortcutType } = useSidebarShortcuts();
|
||||
const audioPlayer = useAudioPlayer();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
serverUrl: '',
|
||||
@@ -835,6 +837,87 @@ const SettingsPage = () => {
|
||||
</Card>
|
||||
|
||||
{/* 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">
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
|
||||
@@ -286,9 +286,9 @@ class DownloadManager {
|
||||
quality === 'medium' ? 192 :
|
||||
quality === 'low' ? 128 : undefined;
|
||||
|
||||
const format = quality === 'low' ? 'mp3' : undefined; // Use mp3 for low quality, original otherwise
|
||||
|
||||
return api.getStreamUrl(songId, { maxBitRate, format });
|
||||
// Note: format parameter is not supported by the Navidrome API
|
||||
// The server will automatically transcode based on maxBitRate
|
||||
return api.getStreamUrl(songId, maxBitRate);
|
||||
}
|
||||
|
||||
// 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;
|
||||
type: string;
|
||||
starred?: string;
|
||||
replayGain?: number;
|
||||
}
|
||||
|
||||
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[]) {
|
||||
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-config-next": "15.4.5",
|
||||
"postcss": "^8",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"overrides": {
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6"
|
||||
"@types/react-dom": "19.1.6",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6"
|
||||
"@types/react-dom": "19.1.6",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"sharp",
|
||||
|
||||
43
pnpm-lock.yaml
generated
43
pnpm-lock.yaml
generated
@@ -7,6 +7,7 @@ settings:
|
||||
overrides:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6
|
||||
typescript: 5.9.2
|
||||
|
||||
importers:
|
||||
|
||||
@@ -214,11 +215,14 @@ importers:
|
||||
postcss:
|
||||
specifier: ^8
|
||||
version: 8.5.6
|
||||
source-map-support:
|
||||
specifier: ^0.5.21
|
||||
version: 0.5.21
|
||||
tailwindcss:
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11
|
||||
typescript:
|
||||
specifier: ^5
|
||||
specifier: 5.9.2
|
||||
version: 5.9.2
|
||||
|
||||
packages:
|
||||
@@ -1622,20 +1626,20 @@ packages:
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.38.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':
|
||||
resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
typescript: 5.9.2
|
||||
|
||||
'@typescript-eslint/scope-manager@8.38.0':
|
||||
resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==}
|
||||
@@ -1645,14 +1649,14 @@ packages:
|
||||
resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
typescript: 5.9.2
|
||||
|
||||
'@typescript-eslint/type-utils@8.38.0':
|
||||
resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
typescript: 5.9.2
|
||||
|
||||
'@typescript-eslint/types@8.38.0':
|
||||
resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==}
|
||||
@@ -1662,14 +1666,14 @@ packages:
|
||||
resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <5.9.0'
|
||||
typescript: 5.9.2
|
||||
|
||||
'@typescript-eslint/utils@8.38.0':
|
||||
resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==}
|
||||
@@ -1868,6 +1872,9 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2130,7 +2137,7 @@ packages:
|
||||
resolution: {integrity: sha512-IMijiXaZ43qFB+Gcpnb374ipTKD8JIyVNR+6VsifFQ/LHyx+A9wgcgSIhCX5PYSjwOoSYD5LtNHKlM5uc23eww==}
|
||||
peerDependencies:
|
||||
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
|
||||
typescript: '>=3.3.1'
|
||||
typescript: 5.9.2
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
@@ -3146,6 +3153,13 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -3250,7 +3264,7 @@ packages:
|
||||
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
|
||||
engines: {node: '>=18.12'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
typescript: 5.9.2
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
|
||||
@@ -4959,6 +4973,8 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -6417,6 +6433,13 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
|
||||
Reference in New Issue
Block a user