feat: add full screen audio player and radio station management
- Implemented FullScreenPlayer component for enhanced audio playback experience. - Added functionality to toggle full screen mode in AudioPlayer. - Introduced NavidromeConfigContext for managing Navidrome server configurations. - Created RadioStationsPage for managing internet radio stations, including adding, deleting, and playing stations. - Enhanced SettingsPage to configure Navidrome server connection with validation and feedback. - Updated NavidromeAPI to support fetching and managing radio stations. - Integrated lyrics fetching and display in FullScreenPlayer using LrcLibClient.
This commit is contained in:
372
app/components/FullScreenPlayer.tsx
Normal file
372
app/components/FullScreenPlayer.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { lrcLibClient } from '@/lib/lrclib';
|
||||
import {
|
||||
FaPlay,
|
||||
FaPause,
|
||||
FaVolumeHigh,
|
||||
FaForward,
|
||||
FaBackward,
|
||||
FaVolumeXmark,
|
||||
FaShuffle,
|
||||
FaRepeat,
|
||||
FaXmark
|
||||
} from "react-icons/fa6";
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface LyricLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface FullScreenPlayerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onClose }) => {
|
||||
const { currentTrack, playPreviousTrack, playNextTrack } = useAudioPlayer();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [dominantColor, setDominantColor] = useState('#1a1a1a');
|
||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
||||
const [showLyrics, setShowLyrics] = useState(true);
|
||||
const lyricsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load lyrics when track changes
|
||||
useEffect(() => {
|
||||
const loadLyrics = async () => {
|
||||
if (!currentTrack) {
|
||||
setLyrics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lyricsData = await lrcLibClient.searchTrack(
|
||||
currentTrack.artist,
|
||||
currentTrack.name,
|
||||
currentTrack.album,
|
||||
currentTrack.duration
|
||||
);
|
||||
|
||||
if (lyricsData && lyricsData.syncedLyrics) {
|
||||
const parsedLyrics = lrcLibClient.parseSyncedLyrics(lyricsData.syncedLyrics);
|
||||
setLyrics(parsedLyrics);
|
||||
} else {
|
||||
setLyrics([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load lyrics:', error);
|
||||
setLyrics([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadLyrics();
|
||||
}, [currentTrack]);
|
||||
|
||||
// Update current lyric index based on time
|
||||
useEffect(() => {
|
||||
const newIndex = lrcLibClient.getCurrentLyricIndex(lyrics, currentTime);
|
||||
setCurrentLyricIndex(newIndex);
|
||||
}, [lyrics, currentTime]);
|
||||
|
||||
// Auto-scroll lyrics to center current line
|
||||
useEffect(() => {
|
||||
if (currentLyricIndex >= 0 && lyrics.length > 0) {
|
||||
const lyricsContainer = document.querySelector('.lyrics-container');
|
||||
if (lyricsContainer) {
|
||||
const currentLyricElement = lyricsContainer.children[currentLyricIndex] as HTMLElement;
|
||||
if (currentLyricElement) {
|
||||
currentLyricElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentLyricIndex, lyrics.length]);
|
||||
|
||||
// Sync with main audio player
|
||||
useEffect(() => {
|
||||
const syncWithMainPlayer = () => {
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
if (mainAudio && currentTrack) {
|
||||
setCurrentTime(mainAudio.currentTime);
|
||||
setDuration(mainAudio.duration || 0);
|
||||
setProgress(mainAudio.duration ? (mainAudio.currentTime / mainAudio.duration) * 100 : 0);
|
||||
setIsPlaying(!mainAudio.paused);
|
||||
setVolume(mainAudio.volume);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
// Initial sync
|
||||
syncWithMainPlayer();
|
||||
|
||||
// Set up interval to keep syncing
|
||||
const interval = setInterval(syncWithMainPlayer, 100);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen, currentTrack]);
|
||||
|
||||
// Extract dominant color from cover art
|
||||
useEffect(() => {
|
||||
if (!currentTrack?.coverArt) return;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Simple dominant color extraction
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r += data[i];
|
||||
g += data[i + 1];
|
||||
b += data[i + 2];
|
||||
}
|
||||
|
||||
const pixelCount = data.length / 4;
|
||||
r = Math.floor(r / pixelCount);
|
||||
g = Math.floor(g / pixelCount);
|
||||
b = Math.floor(b / pixelCount);
|
||||
|
||||
setDominantColor(`rgb(${r}, ${g}, ${b})`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to extract color:', error);
|
||||
}
|
||||
};
|
||||
img.src = currentTrack.coverArt;
|
||||
}, [currentTrack]);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
if (!mainAudio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
mainAudio.pause();
|
||||
} else {
|
||||
mainAudio.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
if (!mainAudio || !duration) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = (x / rect.width) * 100;
|
||||
const newTime = (percentage / 100) * duration;
|
||||
|
||||
mainAudio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const mainAudio = document.querySelector('audio') as HTMLAudioElement;
|
||||
if (!mainAudio) return;
|
||||
|
||||
const newVolume = parseInt(e.target.value) / 100;
|
||||
mainAudio.volume = newVolume;
|
||||
setVolume(newVolume);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!isOpen || !currentTrack) return null;
|
||||
|
||||
const backgroundStyle = {
|
||||
background: `linear-gradient(135deg, ${dominantColor}40 0%, ${dominantColor}20 50%, transparent 100%)`
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-95 backdrop-blur-sm overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col" style={backgroundStyle}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 lg:p-6 flex-shrink-0">
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-white">Now Playing</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<FaXmark className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-8 p-4 lg:p-6 pt-0 overflow-hidden min-h-0">
|
||||
{/* Left Side - Album Art and Controls */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center max-w-2xl mx-auto lg:mx-0 min-h-0">
|
||||
{/* Album Art */}
|
||||
<div className="relative mb-4 lg:mb-8 flex-shrink-0">
|
||||
<Image
|
||||
src={currentTrack.coverArt || '/default-album.png'}
|
||||
alt={currentTrack.album}
|
||||
width={320}
|
||||
height={320}
|
||||
className="w-64 h-64 lg:w-80 lg:h-80 rounded-lg shadow-2xl object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track Info */}
|
||||
<div className="text-center mb-4 lg:mb-8 px-4 flex-shrink-0">
|
||||
<h1 className="text-xl lg:text-3xl font-bold text-foreground mb-2 line-clamp-2">
|
||||
{currentTrack.name}
|
||||
</h1>
|
||||
<p className="text-lg lg:text-xl text-foreground/80 mb-1 line-clamp-1">{currentTrack.artist}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full max-w-md mb-4 lg:mb-6 px-4 flex-shrink-0">
|
||||
<div
|
||||
className="h-2 bg-white/20 rounded-full cursor-pointer relative overflow-hidden"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-foreground transition-all duration-150"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-foreground/60 mt-2">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4 lg:gap-6 mb-4 lg:mb-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={playPreviousTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaBackward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{isPlaying ? (
|
||||
<FaPause className="w-10 h-10" />
|
||||
) : (
|
||||
<FaPlay className="w-10 h-10" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={playNextTrack}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
<FaForward className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<button
|
||||
onMouseEnter={() => setShowVolumeSlider(true)}
|
||||
className="p-2 hover:bg-gray-700/50 rounded-full transition-colors">
|
||||
{volume === 0 ? (
|
||||
<FaVolumeXmark className="w-5 h-5" />
|
||||
) : (
|
||||
<FaVolumeHigh className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showVolumeSlider && (
|
||||
<div
|
||||
className="w-20 lg:w-24"
|
||||
onMouseLeave={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-full accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Lyrics */}
|
||||
{showLyrics && lyrics.length > 0 && (
|
||||
<div className="flex-1 lg:max-w-md min-h-0">
|
||||
<Card className="bg-black/30 backdrop-blur-sm border-white/20 h-full">
|
||||
<CardContent className="p-4 lg:p-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<h3 className="text-lg font-semibold text-foreground">Lyrics</h3>
|
||||
<button
|
||||
onClick={() => setShowLyrics(false)}
|
||||
className="text-foreground/60 hover:bg-foreground/20"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-3 pr-4">
|
||||
{lyrics.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-sm leading-relaxed transition-all duration-300 ${
|
||||
index === currentLyricIndex
|
||||
? 'text-foreground font-semibold text-base scale-105'
|
||||
: index < currentLyricIndex
|
||||
? 'text-foreground/60'
|
||||
: 'text-foreground/40'
|
||||
}`}
|
||||
>
|
||||
{line.text || '♪'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Lyrics button when hidden */}
|
||||
{!showLyrics && lyrics.length > 0 && (
|
||||
<div className="lg:flex-1 lg:max-w-md flex items-start justify-center lg:justify-start pt-4 lg:pt-8 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowLyrics(true)}
|
||||
className="bg-foreground/20 hover:bg-foreground/30 text-foreground backdrop-blur-sm"
|
||||
>
|
||||
Show Lyrics
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user