feat: Implement Auto-Tagging Settings and MusicBrainz integration

- Added AutoTaggingSettings component for configuring auto-tagging preferences.
- Integrated localStorage for saving user preferences and options.
- Developed useAutoTagging hook for fetching and applying metadata from MusicBrainz.
- Created MusicBrainz API client for searching and retrieving music metadata.
- Enhanced metadata structure with additional fields for tracks and albums.
- Implemented rate-limiting for MusicBrainz API requests.
- Added UI components for user interaction and feedback during the tagging process.
This commit is contained in:
2025-08-10 15:02:49 +00:00
committed by GitHub
parent cfd4f88b5e
commit ba91d3ee28
10 changed files with 1904 additions and 37 deletions

View File

@@ -1,12 +1,14 @@
'use client';
import React, { useRef, useState, useEffect } from 'react';
import React, { useRef, useState, useEffect, useCallback } 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 { FaPlay, FaPause, FaExpand, FaForward, FaBackward, FaVolumeHigh, FaVolumeXmark } from 'react-icons/fa6';
import { Heart } from 'lucide-react';
import { constrain } from '@/lib/utils';
import { Progress } from '@/components/ui/progress';
import { extractDominantColor } from '@/lib/image-utils';
interface DraggableMiniPlayerProps {
onExpand: () => void;
@@ -24,6 +26,12 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dominantColor, setDominantColor] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [volume, setVolume] = useState(1);
const [clickCount, setClickCount] = useState(0);
const [clickTimer, setClickTimer] = useState<NodeJS.Timeout | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragStartRef = useRef({ x: 0, y: 0 });
@@ -34,6 +42,105 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
}
}, [position, isDragging]);
// Extract dominant color from album art
useEffect(() => {
if (!currentTrack?.coverArt) {
setDominantColor(null);
return;
}
extractDominantColor(currentTrack.coverArt)
.then(color => setDominantColor(color))
.catch(error => {
console.error('Failed to extract color:', error);
setDominantColor(null);
});
}, [currentTrack?.coverArt]);
// Track progress from main audio player
useEffect(() => {
const updateProgress = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement && audioElement.duration) {
setProgress((audioElement.currentTime / audioElement.duration) * 100);
}
};
const updateVolume = () => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (audioElement) {
setVolume(audioElement.volume);
}
};
const interval = setInterval(updateProgress, 250);
updateVolume(); // Initial volume
// Set up event listener for volume changes
const audioElement = document.querySelector('audio');
if (audioElement) {
audioElement.addEventListener('volumechange', updateVolume);
}
return () => {
clearInterval(interval);
if (audioElement) {
audioElement.removeEventListener('volumechange', updateVolume);
}
};
}, [currentTrack]);
// Detect double clicks for expanding
const handleContainerClick = useCallback(() => {
setClickCount(prev => prev + 1);
if (clickTimer) {
clearTimeout(clickTimer);
}
const timer = setTimeout(() => {
// If single click, do nothing
if (clickCount === 0) {
// Nothing
}
// If double click, expand
else if (clickCount === 1) {
onExpand();
}
setClickCount(0);
}, 300);
setClickTimer(timer as unknown as NodeJS.Timeout);
}, [clickCount, clickTimer, onExpand]);
// Handle seeking in track
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percent = clickX / rect.width;
audioElement.currentTime = percent * audioElement.duration;
};
// Handle volume change
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const audioElement = document.querySelector('audio') as HTMLAudioElement | null;
if (!audioElement) return;
const newVolume = parseFloat(e.target.value);
audioElement.volume = newVolume;
setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch (error) {
console.error('Failed to save volume:', error);
}
};
// Keyboard controls for the mini player
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -106,7 +213,7 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
}
}, []);
// Ensure player stays within viewport bounds
// Ensure player stays within viewport bounds and implement edge snapping
useEffect(() => {
const constrainToViewport = () => {
if (!containerRef.current || isDragging) return;
@@ -118,17 +225,41 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
// Add some padding from edges
const padding = 16;
const newX = constrain(
// Calculate constrained position
let newX = constrain(
position.x,
-(viewportWidth - rect.width) / 2 + padding,
(viewportWidth - rect.width) / 2 - padding
);
const newY = constrain(
let newY = constrain(
position.y,
-(viewportHeight - rect.height) / 2 + padding,
(viewportHeight - rect.height) / 2 - padding
);
// Edge snapping logic
const snapThreshold = 24; // Pixels from edge to trigger snap
const snapPositions = {
left: -(viewportWidth - rect.width) / 2 + padding,
right: (viewportWidth - rect.width) / 2 - padding,
top: -(viewportHeight - rect.height) / 2 + padding,
bottom: (viewportHeight - rect.height) / 2 - padding,
};
// Snap to left or right edge
if (Math.abs(newX - snapPositions.left) < snapThreshold) {
newX = snapPositions.left;
} else if (Math.abs(newX - snapPositions.right) < snapThreshold) {
newX = snapPositions.right;
}
// Snap to top or bottom edge
if (Math.abs(newY - snapPositions.top) < snapThreshold) {
newY = snapPositions.top;
} else if (Math.abs(newY - snapPositions.bottom) < snapThreshold) {
newY = snapPositions.bottom;
}
if (newX !== position.x || newY !== position.y) {
setPosition({ x: newX, y: newY });
@@ -181,18 +312,54 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
transform: `translate(-50%, -50%)`
}}
className="cursor-grab active:cursor-grabbing"
onClick={handleContainerClick}
>
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-xl hover:shadow-2xl transition-shadow p-3 w-[280px]">
<div
className="backdrop-blur-sm border rounded-lg shadow-xl hover:shadow-2xl transition-shadow p-3 w-[280px]"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.15)')}`
: 'var(--background-color, rgba(0, 0, 0, 0.8))',
borderColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.3)')}`
: 'var(--border-color, rgba(255, 255, 255, 0.1))'
}}
>
{/* Progress bar at the top */}
<div className="mb-3" onClick={handleProgressClick}>
<Progress
value={progress}
className="h-1 cursor-pointer"
style={{
backgroundColor: dominantColor
? `${dominantColor.replace('rgb', 'rgba').replace(')', ', 0.2)')}`
: undefined,
'--progress-color': dominantColor || undefined
} as React.CSSProperties}
/>
</div>
<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>
{/* Album Art - Animated transition */}
<AnimatePresence mode="wait">
<motion.div
key={currentTrack.id}
className="relative w-12 h-12 shrink-0"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
>
<Image
src={currentTrack.coverArt || '/default-user.jpg'}
alt={currentTrack.name}
fill
className="rounded-md object-cover shadow-md"
sizes="48px"
priority
/>
</motion.div>
</AnimatePresence>
{/* Track Info */}
<div className="flex-1 min-w-0">
@@ -201,13 +368,13 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
</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="text-xs text-muted-foreground text-center mt-2 px-2">
Double-click to expand Arrow keys to move
</div>
<div className="flex items-center justify-between mt-2 px-2">
{/* Controls */}
<div className="flex items-center justify-between mt-2 px-2">
<button
onClick={(e) => {
e.stopPropagation();
@@ -257,16 +424,53 @@ export const DraggableMiniPlayer: React.FC<DraggableMiniPlayerProps> = ({ onExpa
</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 className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setShowVolumeSlider(prev => !prev);
}}
className="p-2 hover:bg-muted/50 rounded-full transition-colors"
title="Volume"
>
{volume === 0 ? (
<FaVolumeXmark className="w-4 h-4" />
) : (
<FaVolumeHigh className="w-4 h-4" />
)}
</button>
{/* Volume Slider */}
{showVolumeSlider && (
<div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg"
onClick={e => e.stopPropagation()}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={handleVolumeChange}
className="w-24 accent-foreground"
/>
</div>
)}
</div>
</div>
{/* Expand button in top-right corner */}
<button
onClick={(e) => {
e.stopPropagation();
onExpand();
}}
className="absolute top-2 right-2 p-1.5 hover:bg-muted/50 rounded-full transition-colors"
title="Expand"
>
<FaExpand className="w-3 h-3" />
</button>
</div>
</motion.div>
</AnimatePresence>