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

@@ -0,0 +1,73 @@
'use client';
import React, { useState } from 'react';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { MusicIcon, TagIcon, InfoIcon } from 'lucide-react';
import { AutoTaggingDialog } from './AutoTaggingDialog';
interface AutoTagContextMenuProps {
children: React.ReactNode;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export function AutoTagContextMenu({
children,
mode,
itemId,
itemName,
artistName
}: AutoTagContextMenuProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<ContextMenu>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem
onClick={() => setIsDialogOpen(true)}
className="cursor-pointer"
>
<TagIcon className="mr-2 h-4 w-4" />
Auto-Tag {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</ContextMenuItem>
{mode === 'track' && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer">
<InfoIcon className="mr-2 h-4 w-4" />
View Track Details
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer">
<MusicIcon className="mr-2 h-4 w-4" />
Edit Track Metadata
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
<AutoTaggingDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
mode={mode}
itemId={itemId}
itemName={itemName}
artistName={artistName}
/>
</>
);
}
export default AutoTagContextMenu;

View File

@@ -0,0 +1,319 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useToast } from "@/hooks/use-toast";
import { useAutoTagging, EnhancedTrackMetadata, EnhancedAlbumMetadata } from "@/hooks/use-auto-tagging";
import { useIsMobile } from "@/hooks/use-mobile";
import {
MusicIcon,
AlbumIcon,
UsersIcon,
CheckCircle2Icon,
XCircleIcon,
AlertTriangleIcon,
InfoIcon
} from 'lucide-react';
import Image from 'next/image';
interface AutoTaggingDialogProps {
isOpen: boolean;
onClose: () => void;
mode: 'track' | 'album' | 'artist';
itemId: string;
itemName: string;
artistName?: string;
}
export const AutoTaggingDialog: React.FC<AutoTaggingDialogProps> = ({
isOpen,
onClose,
mode,
itemId,
itemName,
artistName
}) => {
const isMobile = useIsMobile();
const { toast } = useToast();
const [confidenceThreshold, setConfidenceThreshold] = useState(70);
const [activeTab, setActiveTab] = useState<'tracks' | 'albums'>('tracks');
const [isApplying, setIsApplying] = useState(false);
const {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
} = useAutoTagging();
// Start auto-tagging when the dialog is opened
useEffect(() => {
if (isOpen && itemId && !isProcessing && progress === 0) {
// Wrap in try/catch to handle any errors that might occur during auto-tagging
try {
startAutoTagging(mode, itemId, confidenceThreshold);
} catch (error) {
console.error('Failed to start auto-tagging:', error);
toast({
title: "Auto-Tagging Error",
description: error instanceof Error ? error.message : "Failed to start auto-tagging",
variant: "destructive",
});
onClose();
}
}
}, [isOpen, itemId, mode, isProcessing, progress, startAutoTagging, confidenceThreshold, toast, onClose]);
// Set the active tab based on the mode
useEffect(() => {
if (mode === 'track') {
setActiveTab('tracks');
} else if (mode === 'album' || mode === 'artist') {
setActiveTab('albums');
}
}, [mode]);
const handleApplyMetadata = async () => {
try {
setIsApplying(true);
await applyEnhancedMetadata(
enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold),
enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold)
);
onClose();
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Error",
description: "Failed to apply metadata",
variant: "destructive",
});
} finally {
setIsApplying(false);
}
};
// Get match statistics
const matchedTracks = enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold).length;
const totalTracks = enhancedTracks.length;
const matchedAlbums = enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold).length;
const totalAlbums = enhancedAlbums.length;
const getStatusIcon = (status: 'pending' | 'matched' | 'failed' | 'applied', confidence: number) => {
if (status === 'pending') return <AlertTriangleIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'failed') return <XCircleIcon className="w-4 h-4 text-red-500" />;
if (status === 'matched' && confidence >= confidenceThreshold) return <CheckCircle2Icon className="w-4 h-4 text-green-500" />;
if (status === 'matched' && confidence < confidenceThreshold) return <InfoIcon className="w-4 h-4 text-yellow-500" />;
if (status === 'applied') return <CheckCircle2Icon className="w-4 h-4 text-blue-500" />;
return null;
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 90) return 'bg-green-500';
if (confidence >= 70) return 'bg-green-400';
if (confidence >= 50) return 'bg-yellow-500';
return 'bg-red-500';
};
// Render the appropriate dialog/sheet based on mobile status
const DialogComponent = isMobile ? Sheet : Dialog;
const DialogContentComponent = isMobile ? SheetContent : DialogContent;
const DialogHeaderComponent = isMobile ? SheetHeader : DialogHeader;
const DialogTitleComponent = isMobile ? SheetTitle : DialogTitle;
const DialogDescriptionComponent = isMobile ? SheetDescription : DialogDescription;
return (
<DialogComponent open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContentComponent className={isMobile ? "p-0 pt-8" : "max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"}>
<DialogHeaderComponent className={isMobile ? "p-6 pb-2" : ""}>
<DialogTitleComponent>
Auto-Tagging {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'}
</DialogTitleComponent>
<DialogDescriptionComponent>
{isProcessing ? (
`Analyzing ${mode === 'track' ? 'track' : mode === 'album' ? 'album' : 'artist'} "${itemName}"`
) : (
`Found metadata for ${matchedTracks} of ${totalTracks} tracks${totalAlbums > 0 ? ` and ${matchedAlbums} of ${totalAlbums} albums` : ''}`
)}
</DialogDescriptionComponent>
{/* Progress bar */}
{(isProcessing || isApplying) && (
<div className="my-4">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground mt-2">
{isProcessing ? 'Analyzing metadata...' : 'Applying metadata...'}
</p>
</div>
)}
</DialogHeaderComponent>
{/* Tabs for tracks and albums */}
{!isProcessing && !isApplying && (
<div className={`flex-1 overflow-hidden flex flex-col ${isMobile ? "px-6" : ""}`}>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'tracks' | 'albums')} className="flex-1 flex flex-col">
<div className="flex justify-between items-center mb-2">
<TabsList>
<TabsTrigger value="tracks" disabled={totalTracks === 0}>
<MusicIcon className="w-4 h-4 mr-2" /> Tracks ({matchedTracks}/{totalTracks})
</TabsTrigger>
<TabsTrigger value="albums" disabled={totalAlbums === 0}>
<AlbumIcon className="w-4 h-4 mr-2" /> Albums ({matchedAlbums}/{totalAlbums})
</TabsTrigger>
</TabsList>
{/* Confidence threshold slider */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">Min. Confidence: {confidenceThreshold}%</span>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(parseInt(e.target.value))}
className="w-24"
/>
</div>
</div>
{/* Tracks tab content */}
<TabsContent value="tracks" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="rounded-md border">
<div className="bg-muted p-2 grid grid-cols-12 gap-2 text-sm font-medium">
<div className="col-span-1"></div>
<div className="col-span-4">Title</div>
<div className="col-span-3">Artist</div>
<div className="col-span-2">Album</div>
<div className="col-span-2 text-right">Confidence</div>
</div>
<div className="divide-y max-h-[50vh] overflow-auto">
{enhancedTracks.map(track => (
<div key={track.id} className="grid grid-cols-12 gap-2 p-2 items-center">
<div className="col-span-1">
{getStatusIcon(track.status, track.confidence)}
</div>
<div className="col-span-4 truncate">
{track.title}
</div>
<div className="col-span-3 truncate">
{track.artist}
</div>
<div className="col-span-2 truncate">
{track.album}
</div>
<div className="col-span-2 flex justify-end items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(track.confidence)}`}
style={{ width: `${track.confidence}%` }}
/>
</div>
<span className="text-xs">{track.confidence}%</span>
</div>
</div>
))}
</div>
</div>
</TabsContent>
{/* Albums tab content */}
<TabsContent value="albums" className="flex-1 overflow-auto data-[state=active]:flex flex-col">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-[50vh] overflow-auto p-1">
{enhancedAlbums.map(album => (
<div key={album.id} className="border rounded-lg overflow-hidden">
<div className="flex">
{/* Album cover */}
<div className="relative w-24 h-24">
{album.coverArtUrl ? (
<Image
src={album.coverArtUrl}
alt={album.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted flex items-center justify-center">
<AlbumIcon className="w-8 h-8 text-muted-foreground" />
</div>
)}
{/* Status badge */}
<div className="absolute top-1 left-1">
{getStatusIcon(album.status, album.confidence)}
</div>
</div>
{/* Album info */}
<div className="flex-1 p-3">
<h4 className="font-medium text-sm truncate">{album.name}</h4>
<p className="text-xs text-muted-foreground truncate">{album.artist}</p>
<div className="mt-2 flex items-center gap-2">
<div className="h-2 w-10 rounded-full bg-gray-200">
<div
className={`h-full rounded-full ${getConfidenceColor(album.confidence)}`}
style={{ width: `${album.confidence}%` }}
/>
</div>
<span className="text-xs">{album.confidence}%</span>
</div>
{album.year && (
<p className="text-xs mt-1">Year: {album.year}</p>
)}
</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
)}
<DialogFooter className={`${isMobile ? "p-6 pt-4" : "mt-4"}`}>
<div className="w-full flex flex-col md:flex-row justify-end gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={isProcessing || isApplying}
>
Cancel
</Button>
<Button
onClick={handleApplyMetadata}
disabled={
isProcessing ||
isApplying ||
(matchedTracks === 0 && matchedAlbums === 0)
}
>
Apply Metadata
</Button>
</div>
</DialogFooter>
</DialogContentComponent>
</DialogComponent>
);
};
export default AutoTaggingDialog;

View File

@@ -0,0 +1,221 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { FaTags } from 'react-icons/fa';
import { useToast } from '@/hooks/use-toast';
import { AutoTaggingDialog } from './AutoTaggingDialog';
export const AutoTaggingSettings = () => {
const { toast } = useToast();
const [isClient, setIsClient] = useState(false);
const [autoTaggingEnabled, setAutoTaggingEnabled] = useState(false);
const [autoTagDialogOpen, setAutoTagDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState({
id: '',
name: 'Library',
mode: 'artist' as 'track' | 'album' | 'artist'
});
const [autoTagOptions, setAutoTagOptions] = useState({
rateLimit: 1000, // milliseconds between requests
autoProcess: false,
preferLocalMetadata: true,
tagsToUpdate: ['title', 'artist', 'album', 'year', 'genre'],
});
useEffect(() => {
setIsClient(true);
// Load saved preferences from localStorage
const savedAutoTagging = localStorage.getItem('auto-tagging-enabled');
if (savedAutoTagging !== null) {
setAutoTaggingEnabled(savedAutoTagging === 'true');
}
// Load saved auto-tag options
const savedOptions = localStorage.getItem('auto-tagging-options');
if (savedOptions !== null) {
try {
setAutoTagOptions(JSON.parse(savedOptions));
} catch (error) {
console.error('Failed to parse stored auto-tagging options:', error);
}
}
}, []);
const handleAutoTaggingToggle = (enabled: boolean) => {
setAutoTaggingEnabled(enabled);
if (isClient) {
localStorage.setItem('auto-tagging-enabled', enabled.toString());
}
toast({
title: enabled ? 'Auto-Tagging Enabled' : 'Auto-Tagging Disabled',
description: enabled
? 'Music will be automatically tagged with metadata from MusicBrainz'
: 'Auto-tagging has been disabled',
});
};
const handleOptionsChange = (key: string, value: unknown) => {
setAutoTagOptions(prev => {
const newOptions = { ...prev, [key]: value };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const handleTagSelectionChange = (tag: string, isSelected: boolean) => {
setAutoTagOptions(prev => {
const currentTags = [...prev.tagsToUpdate];
const newTags = isSelected
? [...currentTags, tag]
: currentTags.filter(t => t !== tag);
const newOptions = { ...prev, tagsToUpdate: newTags };
if (isClient) {
localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions));
}
return newOptions;
});
};
const isTagSelected = (tag: string) => {
return autoTagOptions.tagsToUpdate.includes(tag);
};
return (
<>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FaTags className="w-5 h-5" />
Auto-Tagging
</CardTitle>
<CardDescription>
Configure metadata auto-tagging with MusicBrainz
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Enable Auto-Tagging</p>
<p className="text-sm text-muted-foreground">
Automatically fetch and apply metadata from MusicBrainz
</p>
</div>
<Switch
checked={autoTaggingEnabled}
onCheckedChange={handleAutoTaggingToggle}
/>
</div>
{autoTaggingEnabled && (
<>
<div className="space-y-2">
<Label htmlFor="rate-limit">API Rate Limit (ms)</Label>
<Input
id="rate-limit"
type="number"
min={500}
max={5000}
step={100}
value={autoTagOptions.rateLimit}
onChange={(e) => handleOptionsChange('rateLimit', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Time between API requests in milliseconds (min: 500ms)
</p>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Auto Process Results</p>
<p className="text-sm text-muted-foreground">
Automatically apply best matches without confirmation
</p>
</div>
<Switch
checked={autoTagOptions.autoProcess}
onCheckedChange={(checked) => handleOptionsChange('autoProcess', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Prefer Local Metadata</p>
<p className="text-sm text-muted-foreground">
Keep existing metadata when confidence is low
</p>
</div>
<Switch
checked={autoTagOptions.preferLocalMetadata}
onCheckedChange={(checked) => handleOptionsChange('preferLocalMetadata', checked)}
/>
</div>
<div className="space-y-2">
<Label>Tags to Update</Label>
<div className="grid grid-cols-2 gap-2">
{['title', 'artist', 'album', 'year', 'genre', 'albumArtist', 'trackNumber', 'discNumber'].map(tag => (
<div key={tag} className="flex items-center space-x-2">
<Switch
id={`tag-${tag}`}
checked={isTagSelected(tag)}
onCheckedChange={(checked) => handleTagSelectionChange(tag, checked)}
/>
<Label htmlFor={`tag-${tag}`} className="capitalize">
{tag === 'albumArtist' ? 'Album Artist' :
tag === 'trackNumber' ? 'Track Number' :
tag === 'discNumber' ? 'Disc Number' : tag}
</Label>
</div>
))}
</div>
</div>
<div className="pt-2">
<Button onClick={() => {
// Set selected item to represent the whole library
setSelectedItem({
id: 'library',
name: 'Full Library',
mode: 'artist'
});
setAutoTagDialogOpen(true);
}} variant="outline">
<FaTags className="w-4 h-4 mr-2" />
Open Auto-Tagging Tool
</Button>
</div>
</>
)}
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>How it works:</strong></p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li>Metadata is fetched from MusicBrainz when you play tracks</li>
<li>Tags can be applied automatically or manually reviewed</li>
<li>Right-click on tracks or albums to tag them manually</li>
<li>MusicBrainz API has rate limits, so don&apos;t set too fast</li>
</ul>
</div>
</CardContent>
</Card>
<AutoTaggingDialog
isOpen={autoTagDialogOpen}
onClose={() => setAutoTagDialogOpen(false)}
mode={selectedItem.mode}
itemId={selectedItem.id}
itemName={selectedItem.name}
/>
</>
);
};

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>

View File

@@ -16,8 +16,9 @@ import { SidebarCustomization } from '@/app/components/SidebarCustomization';
import { SettingsManagement } from '@/app/components/SettingsManagement';
import { CacheManagement } from '@/app/components/CacheManagement';
import { OfflineManagement } from '@/app/components/OfflineManagement';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa';
import { Settings, ExternalLink } from 'lucide-react';
import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa';
import { Settings, ExternalLink, Tag } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
const SettingsPage = () => {
@@ -788,6 +789,11 @@ const SettingsPage = () => {
<OfflineManagement />
</div>
{/* Auto-Tagging Settings */}
<div className="break-inside-avoid mb-6">
<AutoTaggingSettings />
</div>
<Card className="mb-6 break-inside-avoid py-5">
<CardHeader>
<CardTitle>Appearance</CardTitle>