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:
@@ -3,13 +3,15 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { Album, Artist } from '@/lib/navidrome';
|
import { Album, Artist } from '@/lib/navidrome';
|
||||||
import { useNavidrome } from '@/app/components/NavidromeContext';
|
import { useNavidrome } from '@/app/components/NavidromeContext';
|
||||||
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { AlbumArtwork } from '@/app/components/album-artwork';
|
import { AlbumArtwork } from '@/app/components/album-artwork';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Heart } from 'lucide-react';
|
import { Heart, Play } from 'lucide-react';
|
||||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
|
||||||
import Loading from '@/app/components/loading';
|
import Loading from '@/app/components/loading';
|
||||||
import { getNavidromeAPI } from '@/lib/navidrome';
|
import { getNavidromeAPI } from '@/lib/navidrome';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
export default function ArtistPage() {
|
export default function ArtistPage() {
|
||||||
const { artist: artistId } = useParams();
|
const { artist: artistId } = useParams();
|
||||||
@@ -17,7 +19,10 @@ export default function ArtistPage() {
|
|||||||
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [artist, setArtist] = useState<Artist | null>(null);
|
const [artist, setArtist] = useState<Artist | null>(null);
|
||||||
|
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
|
||||||
const { getArtist, starItem, unstarItem } = useNavidrome();
|
const { getArtist, starItem, unstarItem } = useNavidrome();
|
||||||
|
const { addArtistToQueue, playAlbum, clearQueue } = useAudioPlayer();
|
||||||
|
const { toast } = useToast();
|
||||||
const api = getNavidromeAPI();
|
const api = getNavidromeAPI();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,6 +60,36 @@ export default function ArtistPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePlayArtist = async () => {
|
||||||
|
if (!artist) return;
|
||||||
|
|
||||||
|
setIsPlayingArtist(true);
|
||||||
|
try {
|
||||||
|
// Clear current queue and add all artist albums
|
||||||
|
clearQueue();
|
||||||
|
await addArtistToQueue(artist.id);
|
||||||
|
|
||||||
|
// Start playing the first album if we have any
|
||||||
|
if (artistAlbums.length > 0) {
|
||||||
|
await playAlbum(artistAlbums[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Playing Artist",
|
||||||
|
description: `Now playing all albums by ${artist.name}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play artist:', error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to play artist albums.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPlayingArtist(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
@@ -90,10 +125,20 @@ export default function ArtistPage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
|
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
|
||||||
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
|
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
|
||||||
<Button onClick={handleStar} variant="secondary" className="mr-4">
|
<div className="flex gap-3">
|
||||||
<Heart className={isStarred ? 'text-red-500' : 'text-gray-500'} fill={isStarred ? 'red' : 'none'}/>
|
<Button
|
||||||
{isStarred ? 'Starred' : 'Star Artist'}
|
onClick={handlePlayArtist}
|
||||||
</Button>
|
disabled={isPlayingArtist}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{isPlayingArtist ? 'Adding to Queue...' : 'Play Artist'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleStar} variant="secondary">
|
||||||
|
<Heart className={isStarred ? 'text-red-500' : 'text-gray-500'} fill={isStarred ? 'red' : 'none'}/>
|
||||||
|
{isStarred ? 'Starred' : 'Star Artist'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark } from "react-icons/fa6";
|
import { FullScreenPlayer } from '@/app/components/FullScreenPlayer';
|
||||||
|
import { FaPlay, FaPause, FaVolumeHigh, FaForward, FaBackward, FaCompress, FaVolumeXmark, FaExpand } from "react-icons/fa6";
|
||||||
import ColorThief from '@neutrixs/colorthief';
|
import ColorThief from '@neutrixs/colorthief';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -16,6 +17,7 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
const [volume, setVolume] = useState(1);
|
const [volume, setVolume] = useState(1);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||||
const audioCurrent = audioRef.current;
|
const audioCurrent = audioRef.current;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -229,6 +231,13 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1 ml-2">
|
<div className="flex items-center space-x-1 ml-2">
|
||||||
|
<button
|
||||||
|
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
|
onClick={() => setIsFullScreen(true)}
|
||||||
|
title="Full Screen"
|
||||||
|
>
|
||||||
|
<FaExpand className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
onClick={() => setIsMinimized(true)}
|
onClick={() => setIsMinimized(true)}
|
||||||
@@ -240,6 +249,12 @@ export const AudioPlayer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<audio ref={audioRef} hidden />
|
<audio ref={audioRef} hidden />
|
||||||
|
|
||||||
|
{/* Full Screen Player */}
|
||||||
|
<FullScreenPlayer
|
||||||
|
isOpen={isFullScreen}
|
||||||
|
onClose={() => setIsFullScreen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
app/components/NavidromeConfigContext.tsx
Normal file
101
app/components/NavidromeConfigContext.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { NavidromeConfig } from '@/lib/navidrome';
|
||||||
|
|
||||||
|
interface NavidromeConfigContextType {
|
||||||
|
config: NavidromeConfig;
|
||||||
|
updateConfig: (newConfig: NavidromeConfig) => void;
|
||||||
|
isConnected: boolean;
|
||||||
|
testConnection: (config: NavidromeConfig) => Promise<boolean>;
|
||||||
|
clearConfig: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavidromeConfigContext = createContext<NavidromeConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface NavidromeConfigProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavidromeConfigProvider: React.FC<NavidromeConfigProviderProps> = ({ children }) => {
|
||||||
|
const [config, setConfig] = useState<NavidromeConfig>({
|
||||||
|
serverUrl: '',
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
// Load config from localStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const savedConfig = localStorage.getItem('navidrome-config');
|
||||||
|
if (savedConfig) {
|
||||||
|
try {
|
||||||
|
const parsedConfig = JSON.parse(savedConfig);
|
||||||
|
setConfig(parsedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved Navidrome config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save config to localStorage when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && config.serverUrl) {
|
||||||
|
localStorage.setItem('navidrome-config', JSON.stringify(config));
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const updateConfig = (newConfig: NavidromeConfig) => {
|
||||||
|
setConfig(newConfig);
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async (testConfig: NavidromeConfig): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Import here to avoid server-side issues
|
||||||
|
const { default: NavidromeAPI } = await import('@/lib/navidrome');
|
||||||
|
const api = new NavidromeAPI(testConfig);
|
||||||
|
const result = await api.ping();
|
||||||
|
setIsConnected(result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Connection test failed:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearConfig = () => {
|
||||||
|
setConfig({
|
||||||
|
serverUrl: '',
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
setIsConnected(false);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('navidrome-config');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavidromeConfigContext.Provider value={{
|
||||||
|
config,
|
||||||
|
updateConfig,
|
||||||
|
isConnected,
|
||||||
|
testConnection,
|
||||||
|
clearConfig
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</NavidromeConfigContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNavidromeConfig = (): NavidromeConfigContextType => {
|
||||||
|
const context = useContext(NavidromeConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useNavidromeConfig must be used within a NavidromeConfigProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -18,6 +18,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
const isAlbums = usePathname() === "/library/albums";
|
const isAlbums = usePathname() === "/library/albums";
|
||||||
const isArtists = usePathname() === "/library/artists";
|
const isArtists = usePathname() === "/library/artists";
|
||||||
const isQueue = usePathname() === "/queue";
|
const isQueue = usePathname() === "/queue";
|
||||||
|
const isRadio = usePathname() === "/radio";
|
||||||
const isHistory = usePathname() === "/history";
|
const isHistory = usePathname() === "/history";
|
||||||
const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists";
|
const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists";
|
||||||
|
|
||||||
@@ -84,6 +85,27 @@ export function Sidebar({ className, playlists }: SidebarProps) {
|
|||||||
Queue
|
Queue
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/radio">
|
||||||
|
<Button variant={isRadio ? "secondary" : "ghost"} className="w-full justify-start mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
>
|
||||||
|
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
|
||||||
|
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
|
||||||
|
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19.1"/>
|
||||||
|
</svg>
|
||||||
|
Radio
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import localFont from "next/font/local";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AudioPlayerProvider } from "./components/AudioPlayerContext";
|
import { AudioPlayerProvider } from "./components/AudioPlayerContext";
|
||||||
import { NavidromeProvider } from "./components/NavidromeContext";
|
import { NavidromeProvider } from "./components/NavidromeContext";
|
||||||
|
import { NavidromeConfigProvider } from "./components/NavidromeConfigContext";
|
||||||
import { ThemeProvider } from "./components/ThemeProvider";
|
import { ThemeProvider } from "./components/ThemeProvider";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import type { Viewport } from 'next';
|
import type { Viewport } from 'next';
|
||||||
@@ -77,15 +78,17 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
</head>
|
</head>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}>
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<NavidromeProvider>
|
<NavidromeConfigProvider>
|
||||||
<AudioPlayerProvider>
|
<NavidromeProvider>
|
||||||
<SpeedInsights />
|
<AudioPlayerProvider>
|
||||||
<Analytics />
|
<SpeedInsights />
|
||||||
<Ihateserverside>
|
<Analytics />
|
||||||
{children}
|
<Ihateserverside>
|
||||||
</Ihateserverside>
|
{children}
|
||||||
</AudioPlayerProvider>
|
</Ihateserverside>
|
||||||
</NavidromeProvider>
|
</AudioPlayerProvider>
|
||||||
|
</NavidromeProvider>
|
||||||
|
</NavidromeConfigProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
256
app/radio/page.tsx
Normal file
256
app/radio/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
|
||||||
|
import { getNavidromeAPI, RadioStation } from '@/lib/navidrome';
|
||||||
|
import { FaWifi, FaPlay, FaPlus, FaTrash } from 'react-icons/fa6';
|
||||||
|
|
||||||
|
const RadioStationsPage = () => {
|
||||||
|
const [stations, setStations] = useState<RadioStation[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [newStation, setNewStation] = useState({
|
||||||
|
name: '',
|
||||||
|
streamUrl: '',
|
||||||
|
homePageUrl: ''
|
||||||
|
});
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { playTrack } = useAudioPlayer();
|
||||||
|
|
||||||
|
const loadRadioStations = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
const stationList = await api.getInternetRadioStations();
|
||||||
|
setStations(stationList);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load radio stations:', error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to load radio stations. Please check your Navidrome connection.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRadioStations();
|
||||||
|
}, [loadRadioStations]);
|
||||||
|
|
||||||
|
const addRadioStation = async () => {
|
||||||
|
if (!newStation.name || !newStation.streamUrl) {
|
||||||
|
toast({
|
||||||
|
title: "Missing Information",
|
||||||
|
description: "Please provide both name and stream URL.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
await api.createInternetRadioStation(
|
||||||
|
newStation.name,
|
||||||
|
newStation.streamUrl,
|
||||||
|
newStation.homePageUrl || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Radio station added successfully.",
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewStation({ name: '', streamUrl: '', homePageUrl: '' });
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
await loadRadioStations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add radio station:', error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to add radio station.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRadioStation = async (stationId: string) => {
|
||||||
|
try {
|
||||||
|
const api = getNavidromeAPI();
|
||||||
|
await api.deleteInternetRadioStation(stationId);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Radio station deleted successfully.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadRadioStations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete radio station:', error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to delete radio station.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playRadioStation = (station: RadioStation) => {
|
||||||
|
const radioTrack = {
|
||||||
|
id: `radio-${station.id}`,
|
||||||
|
name: station.name,
|
||||||
|
url: station.streamUrl,
|
||||||
|
artist: 'Internet Radio',
|
||||||
|
album: 'Live Stream',
|
||||||
|
duration: 0, // Radio streams don't have duration
|
||||||
|
albumId: '',
|
||||||
|
artistId: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
playTrack(radioTrack);
|
||||||
|
toast({
|
||||||
|
title: "Playing Radio",
|
||||||
|
description: `Now playing: ${station.name}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl">
|
||||||
|
<div className="text-center">Loading radio stations...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 max-w-4xl">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<FaWifi className="w-8 h-8" />
|
||||||
|
Radio Stations
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">Listen to internet radio streams</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<FaPlus className="w-4 h-4 mr-2" />
|
||||||
|
Add Station
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Radio Station</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new internet radio station to your collection.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="station-name">Station Name</Label>
|
||||||
|
<Input
|
||||||
|
id="station-name"
|
||||||
|
placeholder="e.g., Jazz FM"
|
||||||
|
value={newStation.name}
|
||||||
|
onChange={(e) => setNewStation(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="stream-url">Stream URL</Label>
|
||||||
|
<Input
|
||||||
|
id="stream-url"
|
||||||
|
placeholder="https://stream.example.com/jazz"
|
||||||
|
value={newStation.streamUrl}
|
||||||
|
onChange={(e) => setNewStation(prev => ({ ...prev, streamUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="homepage-url">Homepage URL (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="homepage-url"
|
||||||
|
placeholder="https://www.jazzfm.com"
|
||||||
|
value={newStation.homePageUrl}
|
||||||
|
onChange={(e) => setNewStation(prev => ({ ...prev, homePageUrl: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<Button onClick={addRadioStation}>Add Station</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stations.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<FaWifi className="w-16 h-16 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Radio Stations</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-4">
|
||||||
|
You haven't added any radio stations yet. Click the "Add Station" button to get started.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{stations.map((station) => (
|
||||||
|
<Card key={station.id} className="hover:shadow-md transition-shadow">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<FaWifi className="w-5 h-5" />
|
||||||
|
{station.name}
|
||||||
|
</CardTitle>
|
||||||
|
{station.homePageUrl && (
|
||||||
|
<CardDescription>
|
||||||
|
<a
|
||||||
|
href={station.homePageUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Visit Website
|
||||||
|
</a>
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => playRadioStation(station)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<FaPlay className="w-4 h-4 mr-2" />
|
||||||
|
Play
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteRadioStation(station.id)}
|
||||||
|
>
|
||||||
|
<FaTrash className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RadioStationsPage;
|
||||||
@@ -1,13 +1,111 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTheme } from '@/app/components/ThemeProvider';
|
import { useTheme } from '@/app/components/ThemeProvider';
|
||||||
|
import { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { FaServer, FaUser, FaLock, FaCheck, FaTimes } from 'react-icons/fa';
|
||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { config, updateConfig, isConnected, testConnection, clearConfig } = useNavidromeConfig();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password
|
||||||
|
});
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!formData.serverUrl || !formData.username || !formData.password) {
|
||||||
|
toast({
|
||||||
|
title: "Missing Information",
|
||||||
|
description: "Please fill in all fields before testing the connection.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTesting(true);
|
||||||
|
try {
|
||||||
|
const success = await testConnection({
|
||||||
|
serverUrl: formData.serverUrl,
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast({
|
||||||
|
title: "Connection Successful",
|
||||||
|
description: "Successfully connected to Navidrome server.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "Connection Failed",
|
||||||
|
description: "Could not connect to the server. Please check your settings.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Connection Error",
|
||||||
|
description: "An error occurred while testing the connection.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
if (!formData.serverUrl || !formData.username || !formData.password) {
|
||||||
|
toast({
|
||||||
|
title: "Missing Information",
|
||||||
|
description: "Please fill in all fields.",
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig({
|
||||||
|
serverUrl: formData.serverUrl,
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
toast({
|
||||||
|
title: "Settings Saved",
|
||||||
|
description: "Navidrome configuration has been saved.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearConfig = () => {
|
||||||
|
clearConfig();
|
||||||
|
setFormData({
|
||||||
|
serverUrl: '',
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
toast({
|
||||||
|
title: "Configuration Cleared",
|
||||||
|
description: "Navidrome configuration has been cleared.",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 max-w-2xl">
|
<div className="container mx-auto p-6 max-w-2xl">
|
||||||
@@ -17,6 +115,93 @@ const SettingsPage = () => {
|
|||||||
<p className="text-muted-foreground">Customize your music experience</p>
|
<p className="text-muted-foreground">Customize your music experience</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FaServer className="w-5 h-5" />
|
||||||
|
Navidrome Server
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure connection to your Navidrome music server
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-url">Server URL</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://your-navidrome-server.com"
|
||||||
|
value={formData.serverUrl}
|
||||||
|
onChange={(e) => handleInputChange('serverUrl', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Your password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<FaCheck className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-sm text-green-600">Connected to server</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaTimes className="w-4 h-4 text-red-600" />
|
||||||
|
<span className="text-sm text-red-600">Not connected</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isTesting ? 'Testing...' : 'Test Connection'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveConfig}
|
||||||
|
disabled={!hasUnsavedChanges}
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleClearConfig}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p><strong>Note:</strong> Your credentials are stored locally in your browser</p>
|
||||||
|
<p><strong>Security:</strong> Always use HTTPS for your Navidrome server</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Appearance</CardTitle>
|
<CardTitle>Appearance</CardTitle>
|
||||||
|
|||||||
108
lib/lrclib.ts
Normal file
108
lib/lrclib.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
interface LrcLibTrack {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
trackName: string;
|
||||||
|
artistName: string;
|
||||||
|
albumName: string;
|
||||||
|
duration: number;
|
||||||
|
instrumental: boolean;
|
||||||
|
plainLyrics: string | null;
|
||||||
|
syncedLyrics: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LyricLine {
|
||||||
|
time: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LrcLibClient {
|
||||||
|
private baseUrl = 'https://lrclib.net/api';
|
||||||
|
|
||||||
|
async searchTrack(artist: string, track: string, album?: string, duration?: number): Promise<LrcLibTrack | null> {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
artist_name: artist,
|
||||||
|
track_name: track,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (album) {
|
||||||
|
params.append('album_name', album);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration) {
|
||||||
|
params.append('duration', Math.round(duration).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/search?${params.toString()}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: LrcLibTrack[] = await response.json();
|
||||||
|
|
||||||
|
// Return the best match (first result is usually the best)
|
||||||
|
return results.length > 0 ? results[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to search lyrics:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTrackById(id: number): Promise<LrcLibTrack | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/get/${id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get track by ID:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseSyncedLyrics(syncedLyrics: string): LyricLine[] {
|
||||||
|
if (!syncedLyrics) return [];
|
||||||
|
|
||||||
|
const lines = syncedLyrics.split('\n');
|
||||||
|
const lyricLines: LyricLine[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/\[(\d{2}):(\d{2})\.(\d{2})\](.*)/);
|
||||||
|
if (match) {
|
||||||
|
const minutes = parseInt(match[1], 10);
|
||||||
|
const seconds = parseInt(match[2], 10);
|
||||||
|
const centiseconds = parseInt(match[3], 10);
|
||||||
|
const text = match[4].trim();
|
||||||
|
|
||||||
|
const time = minutes * 60 + seconds + centiseconds / 100;
|
||||||
|
lyricLines.push({ time, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyricLines.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentLyricIndex(lyricLines: LyricLine[], currentTime: number): number {
|
||||||
|
if (lyricLines.length === 0) return -1;
|
||||||
|
|
||||||
|
for (let i = lyricLines.length - 1; i >= 0; i--) {
|
||||||
|
if (currentTime >= lyricLines[i].time) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lrcLibClient = new LrcLibClient();
|
||||||
@@ -82,6 +82,13 @@ export interface Playlist {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RadioStation {
|
||||||
|
id: string;
|
||||||
|
streamUrl: string;
|
||||||
|
name: string;
|
||||||
|
homePageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class NavidromeAPI {
|
class NavidromeAPI {
|
||||||
private config: NavidromeConfig;
|
private config: NavidromeConfig;
|
||||||
private clientName = 'stillnavidrome';
|
private clientName = 'stillnavidrome';
|
||||||
@@ -326,27 +333,90 @@ class NavidromeAPI {
|
|||||||
const searchData = response.searchResult3 as { song?: Song[] };
|
const searchData = response.searchResult3 as { song?: Song[] };
|
||||||
return searchData?.song || [];
|
return searchData?.song || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRadioStations(): Promise<RadioStation[]> {
|
||||||
|
const response = await this.makeRequest('getRadioStations');
|
||||||
|
const radioStationsData = response.radioStations as { radioStation?: RadioStation[] };
|
||||||
|
return radioStationsData?.radioStation || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRadioStation(stationId: string): Promise<RadioStation> {
|
||||||
|
const response = await this.makeRequest('getRadioStation', { id: stationId });
|
||||||
|
return response.radioStation as RadioStation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInternetRadioStations(): Promise<RadioStation[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('getInternetRadioStations');
|
||||||
|
const radioData = response.internetRadioStations as { internetRadioStation?: RadioStation[] };
|
||||||
|
return radioData?.internetRadioStation || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get internet radio stations:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInternetRadioStation(name: string, streamUrl: string, homePageUrl?: string): Promise<void> {
|
||||||
|
const params: Record<string, string> = { name, streamUrl };
|
||||||
|
if (homePageUrl) params.homePageUrl = homePageUrl;
|
||||||
|
await this.makeRequest('createInternetRadioStation', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInternetRadioStation(id: string): Promise<void> {
|
||||||
|
await this.makeRequest('deleteInternetRadioStation', { id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance management
|
// Singleton instance management
|
||||||
let navidromeInstance: NavidromeAPI | null = null;
|
let navidromeInstance: NavidromeAPI | null = null;
|
||||||
|
|
||||||
export function getNavidromeAPI(): NavidromeAPI {
|
export function getNavidromeAPI(customConfig?: NavidromeConfig): NavidromeAPI {
|
||||||
if (!navidromeInstance) {
|
let config: NavidromeConfig;
|
||||||
const config: NavidromeConfig = {
|
|
||||||
serverUrl: process.env.NEXT_PUBLIC_NAVIDROME_URL || '',
|
if (customConfig) {
|
||||||
username: process.env.NEXT_PUBLIC_NAVIDROME_USERNAME || '',
|
config = customConfig;
|
||||||
password: process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD || ''
|
} else {
|
||||||
};
|
// Try to get config from localStorage first (client-side)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
if (!config.serverUrl || !config.username || !config.password) {
|
const savedConfig = localStorage.getItem('navidrome-config');
|
||||||
throw new Error('Navidrome configuration is incomplete. Please check environment variables.');
|
if (savedConfig) {
|
||||||
|
try {
|
||||||
|
config = JSON.parse(savedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved Navidrome config:', error);
|
||||||
|
config = getEnvConfig();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config = getEnvConfig();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server-side: use environment variables
|
||||||
|
config = getEnvConfig();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.serverUrl || !config.username || !config.password) {
|
||||||
|
throw new Error('Navidrome configuration is incomplete. Please configure in settings or check environment variables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always create a new instance if config is provided or if no instance exists
|
||||||
|
if (customConfig || !navidromeInstance) {
|
||||||
navidromeInstance = new NavidromeAPI(config);
|
navidromeInstance = new NavidromeAPI(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
return navidromeInstance;
|
return navidromeInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEnvConfig(): NavidromeConfig {
|
||||||
|
return {
|
||||||
|
serverUrl: process.env.NEXT_PUBLIC_NAVIDROME_URL || '',
|
||||||
|
username: process.env.NEXT_PUBLIC_NAVIDROME_USERNAME || '',
|
||||||
|
password: process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetNavidromeAPI(): void {
|
||||||
|
navidromeInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
export default NavidromeAPI;
|
export default NavidromeAPI;
|
||||||
|
|||||||
Reference in New Issue
Block a user