feat: Update cover art retrieval to use higher resolution images and enhance download manager with new features

This commit is contained in:
2025-08-10 02:06:39 +00:00
committed by GitHub
parent 4b0997c6b4
commit 192148adf2
8 changed files with 189 additions and 46 deletions

View File

@@ -1 +1 @@
NEXT_PUBLIC_COMMIT_SHA=3839a1b NEXT_PUBLIC_COMMIT_SHA=7e6a28e

View File

@@ -64,6 +64,22 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const [activeTab, setActiveTab] = useState<MobileTab>('player'); const [activeTab, setActiveTab] = useState<MobileTab>('player');
const lyricsRef = useRef<HTMLDivElement>(null); const lyricsRef = useRef<HTMLDivElement>(null);
// Initialize volume from saved preference when fullscreen opens
useEffect(() => {
if (!isOpen) return;
try {
const savedVolume = localStorage.getItem('navidrome-volume');
if (savedVolume !== null) {
const vol = parseFloat(savedVolume);
if (!isNaN(vol) && vol >= 0 && vol <= 1) {
setVolume(vol);
const mainAudio = document.querySelector('audio') as HTMLAudioElement | null;
if (mainAudio) mainAudio.volume = vol;
}
}
} catch {}
}, [isOpen]);
// Debug logging for component changes // Debug logging for component changes
useEffect(() => { useEffect(() => {
console.log('🔍 FullScreenPlayer state changed:', { console.log('🔍 FullScreenPlayer state changed:', {
@@ -128,7 +144,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const containerHeight = scrollContainer.clientHeight; const containerHeight = scrollContainer.clientHeight;
const elementTop = currentLyricElement.offsetTop; const elementTop = currentLyricElement.offsetTop;
const elementHeight = currentLyricElement.offsetHeight; const elementHeight = currentLyricElement.offsetHeight;
const targetScrollTop = elementTop - (containerHeight / 2) + (elementHeight / 2); // Position the active lyric higher on the screen (~25% from top)
const focusFraction = 0.25; // 0.5 would be center
const targetScrollTop = elementTop - (containerHeight * focusFraction) + (elementHeight / 2);
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: Math.max(0, targetScrollTop), top: Math.max(0, targetScrollTop),
@@ -379,6 +397,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
mainAudio.currentTime = newTime; mainAudio.currentTime = newTime;
setCurrentTime(newTime); setCurrentTime(newTime);
try {
localStorage.setItem('navidrome-current-track-time', newTime.toString());
} catch {}
}; };
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -388,6 +409,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
const newVolume = parseInt(e.target.value) / 100; const newVolume = parseInt(e.target.value) / 100;
mainAudio.volume = newVolume; mainAudio.volume = newVolume;
setVolume(newVolume); setVolume(newVolume);
try {
localStorage.setItem('navidrome-volume', newVolume.toString());
} catch {}
}; };
const handleLyricClick = (time: number) => { const handleLyricClick = (time: number) => {
@@ -396,6 +420,9 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
mainAudio.currentTime = time; mainAudio.currentTime = time;
setCurrentTime(time); setCurrentTime(time);
try {
localStorage.setItem('navidrome-current-track-time', time.toString());
} catch {}
// Update progress bar as well // Update progress bar as well
if (duration > 0) { if (duration > 0) {
@@ -660,18 +687,18 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
ref={lyricsRef} ref={lyricsRef}
> >
<div className="space-y-3 py-4"> <div className="space-y-4 py-10">
{lyrics.map((line, index) => ( {lyrics.map((line, index) => (
<motion.div <motion.div
key={index} key={index}
data-lyric-index={index} data-lyric-index={index}
onClick={() => handleLyricClick(line.time)} onClick={() => handleLyricClick(line.time)}
initial={false} initial={false}
animate={index === currentLyricIndex ? { scale: 1, opacity: 1 } : index < currentLyricIndex ? { scale: 0.995, opacity: 0.7 } : { scale: 0.99, opacity: 0.5 }} animate={index === currentLyricIndex ? { scale: 1.06, opacity: 1 } : index < currentLyricIndex ? { scale: 0.985, opacity: 0.75 } : { scale: 0.98, opacity: 0.6 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className={`text-base leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground px-2 ${ className={`text-2xl sm:text-3xl leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground px-2 ${
index === currentLyricIndex index === currentLyricIndex
? 'text-foreground font-bold text-xl' ? 'text-foreground font-extrabold leading-tight text-5xl sm:text-6xl'
: index < currentLyricIndex : index < currentLyricIndex
? 'text-foreground/60' ? 'text-foreground/60'
: 'text-foreground/40' : 'text-foreground/40'
@@ -680,14 +707,18 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
wordWrap: 'break-word', wordWrap: 'break-word',
overflowWrap: 'break-word', overflowWrap: 'break-word',
hyphens: 'auto', hyphens: 'auto',
paddingBottom: '4px' paddingBottom: '4px',
// Subtle glow to make the current line feel elevated
textShadow: index === currentLyricIndex
? '0 4px 16px rgba(0,0,0,0.7), 0 0 24px rgba(255,255,255,0.16)'
: undefined
}} }}
title={`Click to jump to ${formatTime(line.time)}`} title={`Click to jump to ${formatTime(line.time)}`}
> >
{line.text || '♪'} {line.text || '♪'}
</motion.div> </motion.div>
))} ))}
<div style={{ height: '200px' }} /> <div style={{ height: '260px' }} />
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -727,8 +758,6 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Mobile Tab Bar */}
<div className="flex-shrink-0 pb-safe"> <div className="flex-shrink-0 pb-safe">
<div className="flex justify-around py-4 mb-2"> <div className="flex justify-around py-4 mb-2">
<button <button
@@ -912,18 +941,18 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
> >
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<ScrollArea className="flex-1 min-h-0"> <ScrollArea className="flex-1 min-h-0">
<div className="space-y-3 pl-4 pr-4 py-4"> <div className="space-y-3 pl-4 pr-4 py-8">
{lyrics.map((line, index) => ( {lyrics.map((line, index) => (
<motion.div <motion.div
key={index} key={index}
data-lyric-index={index} data-lyric-index={index}
onClick={() => handleLyricClick(line.time)} onClick={() => handleLyricClick(line.time)}
initial={false} initial={false}
animate={index === currentLyricIndex ? { scale: 1, opacity: 1 } : index < currentLyricIndex ? { scale: 0.995, opacity: 0.75 } : { scale: 0.99, opacity: 0.5 }} animate={index === currentLyricIndex ? { scale: 1.04, opacity: 1 } : index < currentLyricIndex ? { scale: 0.985, opacity: 0.75 } : { scale: 0.98, opacity: 0.5 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className={`text-base leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground ${ className={`text-base leading-relaxed transition-colors duration-200 break-words cursor-pointer hover:text-foreground ${
index === currentLyricIndex index === currentLyricIndex
? 'text-foreground font-bold text-2xl' ? 'text-foreground font-extrabold leading-tight text-5xl'
: index < currentLyricIndex : index < currentLyricIndex
? 'text-foreground/60' ? 'text-foreground/60'
: 'text-foreground/40' : 'text-foreground/40'
@@ -933,14 +962,18 @@ export const FullScreenPlayer: React.FC<FullScreenPlayerProps> = ({ isOpen, onCl
overflowWrap: 'break-word', overflowWrap: 'break-word',
hyphens: 'auto', hyphens: 'auto',
paddingBottom: '4px', paddingBottom: '4px',
paddingLeft: '8px' paddingLeft: '8px',
// Subtle glow to make the current line feel elevated
textShadow: index === currentLyricIndex
? '0 6px 18px rgba(0,0,0,0.7), 0 0 28px rgba(255,255,255,0.18)'
: undefined
}} }}
title={`Click to jump to ${formatTime(line.time)}`} title={`Click to jump to ${formatTime(line.time)}`}
> >
{line.text || '♪'} {line.text || '♪'}
</motion.div> </motion.div>
))} ))}
<div style={{ height: '200px' }} /> <div style={{ height: '240px' }} />
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>

View File

@@ -109,7 +109,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
try { try {
const api = getNavidromeAPI(); const api = getNavidromeAPI();
const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`; const url = api ? api.getStreamUrl(song.id) : `offline-song-${song.id}`;
const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : undefined; const coverArt = song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : undefined;
const track = { const track = {
id: song.id, id: song.id,
name: song.title, name: song.title,
@@ -140,7 +140,7 @@ export function SongRecommendations({ userName }: SongRecommendationsProps) {
if (albumSongs.length > 0) { if (albumSongs.length > 0) {
const first = albumSongs[0]; const first = albumSongs[0];
const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`; const url = api ? api.getStreamUrl(first.id) : `offline-song-${first.id}`;
const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 64) : undefined; const coverArt = first.coverArt && api ? api.getCoverArtUrl(first.coverArt, 300) : undefined;
const track = { const track = {
id: first.id, id: first.id,
name: first.title, name: first.title,

View File

@@ -114,7 +114,7 @@ export default function SongsPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -135,7 +135,7 @@ export default function SongsPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred

View File

@@ -57,7 +57,7 @@ export default function PlaylistPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -77,7 +77,7 @@ export default function PlaylistPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred
@@ -98,7 +98,7 @@ export default function PlaylistPage() {
artist: song.artist, artist: song.artist,
album: song.album, album: song.album,
duration: song.duration, duration: song.duration,
coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 64) : undefined, coverArt: song.coverArt ? api.getCoverArtUrl(song.coverArt, 300) : undefined,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
starred: !!song.starred starred: !!song.starred

View File

@@ -199,7 +199,7 @@ export default function SearchPage() {
{/* Song Cover */} {/* Song Cover */}
<div className="shrink-0"> <Image <div className="shrink-0"> <Image
src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 64) : '/default-user.jpg'} src={song.coverArt && api ? api.getCoverArtUrl(song.coverArt, 300) : '/default-user.jpg'}
alt={song.album} alt={song.album}
width={48} width={48}
height={48} height={48}

View File

@@ -60,7 +60,7 @@ export function useFavoriteAlbums() {
id: album.id, id: album.id,
name: album.name, name: album.name,
artist: album.artist, artist: album.artist,
coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 64) : undefined coverArt: album.coverArt ? api.getCoverArtUrl(album.coverArt, 300) : undefined
}; };
addFavoriteAlbum(favoriteAlbum); addFavoriteAlbum(favoriteAlbum);
} }

View File

@@ -7,9 +7,14 @@ export interface DownloadProgress {
completed: number; completed: number;
total: number; total: number;
failed: number; failed: number;
status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error'; status: 'idle' | 'starting' | 'downloading' | 'complete' | 'error' | 'paused';
currentSong?: string; currentSong?: string;
currentArtist?: string;
currentAlbum?: string;
error?: string; error?: string;
downloadSpeed?: number; // In bytes per second
timeRemaining?: number; // In seconds
percentComplete?: number; // 0-100
} }
export interface OfflineItem { export interface OfflineItem {
@@ -19,6 +24,10 @@ export interface OfflineItem {
artist: string; artist: string;
downloadedAt: number; downloadedAt: number;
size?: number; size?: number;
bitRate?: number;
duration?: number;
format?: string;
lastPlayed?: number;
} }
export interface OfflineStats { export interface OfflineStats {
@@ -28,6 +37,13 @@ export interface OfflineStats {
metaSize: number; metaSize: number;
downloadedAlbums: number; downloadedAlbums: number;
downloadedSongs: number; downloadedSongs: number;
lastDownload: number | null;
downloadErrors: number;
remainingStorage: number | null;
autoDownloadEnabled: boolean;
downloadQuality: 'original' | 'high' | 'medium' | 'low';
downloadOnWifiOnly: boolean;
priorityContent: string[]; // IDs of albums or playlists that should always be available offline
} }
class DownloadManager { class DownloadManager {
@@ -121,30 +137,107 @@ class DownloadManager {
}); });
} }
async downloadSong(song: Song): Promise<void> { async downloadSong(
song: Song,
options?: { quality?: 'original' | 'high' | 'medium' | 'low', priority?: boolean }
): Promise<void> {
const songWithUrl = { const songWithUrl = {
...song, ...song,
streamUrl: this.getDownloadUrl(song.id), streamUrl: this.getDownloadUrl(song.id, options?.quality),
offlineUrl: `offline-song-${song.id}`, offlineUrl: `offline-song-${song.id}`,
duration: song.duration, duration: song.duration,
bitRate: song.bitRate, bitRate: song.bitRate,
size: song.size size: song.size,
priority: options?.priority || false,
quality: options?.quality || 'original'
}; };
return this.sendMessage('DOWNLOAD_SONG', songWithUrl); return this.sendMessage('DOWNLOAD_SONG', songWithUrl);
} }
async downloadQueue(songs: Song[]): Promise<void> { async downloadQueue(
const songsWithUrls = songs.map(song => ({ songs: Song[],
...song, options?: {
streamUrl: this.getDownloadUrl(song.id), quality?: 'original' | 'high' | 'medium' | 'low',
offlineUrl: `offline-song-${song.id}`, priority?: boolean,
duration: song.duration, onProgressUpdate?: (progress: DownloadProgress) => void
bitRate: song.bitRate, }
size: song.size ): Promise<void> {
})); if (!this.worker) {
throw new Error('Service Worker not available');
}
return this.sendMessage('DOWNLOAD_QUEUE', { songs: songsWithUrls }); return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case 'DOWNLOAD_PROGRESS':
if (options?.onProgressUpdate) {
options.onProgressUpdate(data);
}
break;
case 'DOWNLOAD_COMPLETE':
resolve();
break;
case 'DOWNLOAD_ERROR':
reject(new Error(data.error));
break;
}
};
const songsWithUrls = songs.map(song => ({
...song,
streamUrl: this.getDownloadUrl(song.id, options?.quality),
offlineUrl: `offline-song-${song.id}`,
duration: song.duration,
bitRate: song.bitRate,
size: song.size,
priority: options?.priority || false,
quality: options?.quality || 'original'
}));
this.worker!.postMessage({
type: 'DOWNLOAD_QUEUE',
data: { songs: songsWithUrls }
}, [channel.port2]);
});
}
async pauseDownloads(): Promise<void> {
return this.sendMessage('PAUSE_DOWNLOADS', {});
}
async resumeDownloads(): Promise<void> {
return this.sendMessage('RESUME_DOWNLOADS', {});
}
async cancelDownloads(): Promise<void> {
return this.sendMessage('CANCEL_DOWNLOADS', {});
}
async setDownloadPreferences(preferences: {
quality: 'original' | 'high' | 'medium' | 'low',
wifiOnly: boolean,
autoDownloadRecent: boolean,
autoDownloadFavorites: boolean,
maxStoragePercent: number,
priorityContent?: string[] // IDs of albums or playlists
}): Promise<void> {
return this.sendMessage('SET_DOWNLOAD_PREFERENCES', preferences);
}
async getDownloadPreferences(): Promise<{
quality: 'original' | 'high' | 'medium' | 'low',
wifiOnly: boolean,
autoDownloadRecent: boolean,
autoDownloadFavorites: boolean,
maxStoragePercent: number,
priorityContent: string[]
}> {
return this.sendMessage('GET_DOWNLOAD_PREFERENCES', {});
} }
async enableOfflineMode(settings: { async enableOfflineMode(settings: {
@@ -177,15 +270,25 @@ class DownloadManager {
return this.sendMessage('GET_OFFLINE_ITEMS', {}); return this.sendMessage('GET_OFFLINE_ITEMS', {});
} }
private getDownloadUrl(songId: string): string { private getDownloadUrl(songId: string, quality?: 'original' | 'high' | 'medium' | 'low'): string {
const api = getNavidromeAPI(); const api = getNavidromeAPI();
if (!api) throw new Error('Navidrome server not configured'); if (!api) throw new Error('Navidrome server not configured');
// Use direct download to fetch original file; browser handles transcoding/decoding.
// Fall back to stream URL if the server does not allow downloads. // Use direct download to fetch original file by default
if (typeof (api as any).getDownloadUrl === 'function') { if (quality === 'original' || !quality) {
return (api as any).getDownloadUrl(songId); if (typeof (api as any).getDownloadUrl === 'function') {
return (api as any).getDownloadUrl(songId);
}
} }
return api.getStreamUrl(songId);
// For other quality settings, use the stream URL with appropriate parameters
const maxBitRate = quality === 'high' ? 320 :
quality === 'medium' ? 192 :
quality === 'low' ? 128 : undefined;
const format = quality === 'low' ? 'mp3' : undefined; // Use mp3 for low quality, original otherwise
return api.getStreamUrl(songId, { maxBitRate, format });
} }
// LocalStorage fallback for browsers without service worker support // LocalStorage fallback for browsers without service worker support
@@ -309,7 +412,14 @@ export function useOfflineDownloads() {
imageSize: 0, imageSize: 0,
metaSize: 0, metaSize: 0,
downloadedAlbums: 0, downloadedAlbums: 0,
downloadedSongs: 0 downloadedSongs: 0,
lastDownload: null,
downloadErrors: 0,
remainingStorage: null,
autoDownloadEnabled: false,
downloadQuality: 'original',
downloadOnWifiOnly: true,
priorityContent: []
}); });
useEffect(() => { useEffect(() => {