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:
2025-06-19 20:34:15 +00:00
committed by GitHub
parent 92293ce41a
commit 52bcc81068
10 changed files with 1204 additions and 27 deletions

View File

@@ -3,13 +3,15 @@ import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { Album, Artist } from '@/lib/navidrome';
import { useNavidrome } from '@/app/components/NavidromeContext';
import { useAudioPlayer } from '@/app/components/AudioPlayerContext';
import { AlbumArtwork } from '@/app/components/album-artwork';
import Image from 'next/image';
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 Loading from '@/app/components/loading';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast';
export default function ArtistPage() {
const { artist: artistId } = useParams();
@@ -17,7 +19,10 @@ export default function ArtistPage() {
const [artistAlbums, setArtistAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [artist, setArtist] = useState<Artist | null>(null);
const [isPlayingArtist, setIsPlayingArtist] = useState(false);
const { getArtist, starItem, unstarItem } = useNavidrome();
const { addArtistToQueue, playAlbum, clearQueue } = useAudioPlayer();
const { toast } = useToast();
const api = getNavidromeAPI();
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) {
return <Loading />;
}
@@ -90,13 +125,23 @@ export default function ArtistPage() {
<div className="flex-1">
<h1 className="text-4xl font-bold text-white mb-2">{artist.name}</h1>
<p className="text-white/80 mb-4">{artist.albumCount} albums</p>
<Button onClick={handleStar} variant="secondary" className="mr-4">
<div className="flex gap-3">
<Button
onClick={handlePlayArtist}
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>
{/* Albums Section */}
<div className="space-y-4">

View File

@@ -2,7 +2,8 @@
import React, { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
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 { Progress } from '@/components/ui/progress';
import { useToast } from '@/hooks/use-toast';
@@ -16,6 +17,7 @@ export const AudioPlayer: React.FC = () => {
const [volume, setVolume] = useState(1);
const [isClient, setIsClient] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [isFullScreen, setIsFullScreen] = useState(false);
const audioCurrent = audioRef.current;
const { toast } = useToast();
@@ -229,6 +231,13 @@ export const AudioPlayer: React.FC = () => {
</button>
</div>
<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
className="p-1.5 hover:bg-gray-700/50 rounded-full transition-colors"
onClick={() => setIsMinimized(true)}
@@ -240,6 +249,12 @@ export const AudioPlayer: React.FC = () => {
</div>
</div>
<audio ref={audioRef} hidden />
{/* Full Screen Player */}
<FullScreenPlayer
isOpen={isFullScreen}
onClose={() => setIsFullScreen(false)}
/>
</div>
);
};

View 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>
);
};

View 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;
};

View File

@@ -18,6 +18,7 @@ export function Sidebar({ className, playlists }: SidebarProps) {
const isAlbums = usePathname() === "/library/albums";
const isArtists = usePathname() === "/library/artists";
const isQueue = usePathname() === "/queue";
const isRadio = usePathname() === "/radio";
const isHistory = usePathname() === "/history";
const isSongs = usePathname() === "/library/songs"; const isPlaylists = usePathname() === "/library/playlists";
@@ -84,6 +85,27 @@ export function Sidebar({ className, playlists }: SidebarProps) {
Queue
</Button>
</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>

View File

@@ -5,6 +5,7 @@ import localFont from "next/font/local";
import "./globals.css";
import { AudioPlayerProvider } from "./components/AudioPlayerContext";
import { NavidromeProvider } from "./components/NavidromeContext";
import { NavidromeConfigProvider } from "./components/NavidromeConfigContext";
import { ThemeProvider } from "./components/ThemeProvider";
import { Metadata } from "next";
import type { Viewport } from 'next';
@@ -77,6 +78,7 @@ export default function Layout({ children }: LayoutProps) {
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}>
<ThemeProvider>
<NavidromeConfigProvider>
<NavidromeProvider>
<AudioPlayerProvider>
<SpeedInsights />
@@ -86,6 +88,7 @@ export default function Layout({ children }: LayoutProps) {
</Ihateserverside>
</AudioPlayerProvider>
</NavidromeProvider>
</NavidromeConfigProvider>
</ThemeProvider>
</body>
</html>

256
app/radio/page.tsx Normal file
View 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&apos;t added any radio stations yet. Click the &quot;Add Station&quot; 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;

View File

@@ -1,13 +1,111 @@
'use client';
import React from 'react';
import React, { useState } from 'react';
import { Label } from '@/components/ui/label';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
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 { useNavidromeConfig } from '@/app/components/NavidromeConfigContext';
import { useToast } from '@/hooks/use-toast';
import { FaServer, FaUser, FaLock, FaCheck, FaTimes } from 'react-icons/fa';
const SettingsPage = () => {
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 (
<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>
</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>
<CardHeader>
<CardTitle>Appearance</CardTitle>

108
lib/lrclib.ts Normal file
View 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();

View File

@@ -82,6 +82,13 @@ export interface Playlist {
coverArt?: string;
}
export interface RadioStation {
id: string;
streamUrl: string;
name: string;
homePageUrl?: string;
}
class NavidromeAPI {
private config: NavidromeConfig;
private clientName = 'stillnavidrome';
@@ -326,27 +333,90 @@ class NavidromeAPI {
const searchData = response.searchResult3 as { song?: 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
let navidromeInstance: NavidromeAPI | null = null;
export function getNavidromeAPI(): NavidromeAPI {
if (!navidromeInstance) {
const config: NavidromeConfig = {
serverUrl: process.env.NEXT_PUBLIC_NAVIDROME_URL || '',
username: process.env.NEXT_PUBLIC_NAVIDROME_USERNAME || '',
password: process.env.NEXT_PUBLIC_NAVIDROME_PASSWORD || ''
};
export function getNavidromeAPI(customConfig?: NavidromeConfig): NavidromeAPI {
let config: NavidromeConfig;
if (!config.serverUrl || !config.username || !config.password) {
throw new Error('Navidrome configuration is incomplete. Please check environment variables.');
if (customConfig) {
config = customConfig;
} else {
// Try to get config from localStorage first (client-side)
if (typeof window !== 'undefined') {
const savedConfig = localStorage.getItem('navidrome-config');
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);
}
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;