feat: Enhance audio settings with ReplayGain, crossfade, and equalizer presets; add AudioSettingsDialog component
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user