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

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

View File

@@ -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

View File

@@ -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 (

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { Progress } from '@/components/ui/progress';
import { lrcLibClient } from '@/lib/lrclib';
import 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)}
/>
</>
);
};