feat: v1.30.0 — Discord RPC, offline bulk download, artist images, lazy loading, crossfade fix

- Discord Rich Presence (opt-in) — requested by @Bewenben (#49)
- Bulk offline download for playlists and artist discographies — requested by @Apollosport (#54)
- Offline Library filter tabs: All / Albums / Playlists / Discographies with artist grouping
- Artist images on Artists overview (opt-in, off by default) — reported by @Apollosport (#53)
- Image lazy loading via IntersectionObserver (300px margin) across all pages
- Fix: crossfade no longer triggers on manual track skip — reported by @netherguy4 (#35)
- Fix: playlist offline cache now stored as single entry (not per-album)
- Fix: image cache AbortController no longer blocks IDB writes
- Update toast: experimental auto-update hint + GH download link always visible (Win/Mac)
- Queue tech strip: genre removed
- Facebook theme: contrast, opaque badge/back button, queue tab labels
- "Save discography offline" label (was "Download discography")
- Fix: clearing empty playlists via updatePlaylist.view (Axios empty array workaround)
- starredOverrides propagated to AlbumDetail, Favorites, RandomMix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-03 14:53:19 +02:00
parent 651b3cb050
commit c365140870
29 changed files with 1099 additions and 338 deletions
+129 -69
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Play, HardDriveDownload, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOfflineStore } from '../store/offlineStore';
@@ -7,6 +7,8 @@ import { usePlayerStore } from '../store/playerStore';
import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import CachedImage from '../components/CachedImage';
type FilterType = 'all' | 'album' | 'playlist' | 'artist';
export default function OfflineLibrary() {
const { t } = useTranslation();
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -15,26 +17,36 @@ export default function OfflineLibrary() {
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [filter, setFilter] = useState<FilterType>('all');
const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId);
const buildTracks = (albumId: string) => {
const meta = offlineAlbums[`${serverId}:${albumId}`];
if (!meta) return [];
return meta.trackIds.flatMap(tid => {
const t = offlineTracks[`${serverId}:${tid}`];
if (!t) return [];
return [{
id: t.id, title: t.title, artist: t.artist, album: t.album,
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
coverArt: t.coverArt, track: undefined, year: t.year,
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
replayGainTrackDb: t.replayGainTrackDb,
replayGainAlbumDb: t.replayGainAlbumDb,
replayGainPeak: t.replayGainPeak,
}];
});
};
const countByType = (type: FilterType) => {
if (type === 'all') return albums.length;
return albums.filter(a => (a.type ?? 'album') === type).length;
};
const filtered = filter === 'all'
? albums
: albums.filter(a => (a.type ?? 'album') === filter);
const buildTracks = (albumId: string) => {
const meta = offlineAlbums[`${serverId}:${albumId}`];
if (!meta) return [];
return meta.trackIds.flatMap(tid => {
const t = offlineTracks[`${serverId}:${tid}`];
if (!t) return [];
return [{
id: t.id, title: t.title, artist: t.artist, album: t.album,
albumId: t.albumId, artistId: t.artistId, duration: t.duration,
coverArt: t.coverArt, track: undefined, year: t.year,
bitRate: t.bitRate, suffix: t.suffix, genre: t.genre,
replayGainTrackDb: t.replayGainTrackDb,
replayGainAlbumDb: t.replayGainAlbumDb,
replayGainPeak: t.replayGainPeak,
}];
});
};
const handlePlay = (albumId: string) => {
const tracks = buildTracks(albumId);
@@ -45,6 +57,84 @@ const buildTracks = (albumId: string) => {
enqueue(buildTracks(albumId));
};
const renderCard = (album: typeof albums[0]) => {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
return (
<div key={`${album.serverId}:${album.id}`} className="album-card card offline-library-card">
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
) : (
<div className="album-card-cover-placeholder">
<HardDriveDownload size={32} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={() => handlePlay(album.id)}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
<div className="offline-library-card-meta">
<button
className="offline-library-enqueue"
onClick={() => handleEnqueue(album.id)}
data-tooltip={t('queue.addToQueue')}
data-tooltip-pos="top"
>
+ Queue
</button>
<span className="offline-library-tracks">{trackCount} tracks</span>
<button
className="offline-library-delete"
onClick={() => deleteAlbum(album.id, serverId)}
data-tooltip={t('albumDetail.removeOffline')}
data-tooltip-pos="top"
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
);
};
// For artist filter: group by artist name
const renderArtistGroups = () => {
const groups: Record<string, typeof albums> = {};
for (const album of filtered) {
const key = album.artist || '—';
if (!groups[key]) groups[key] = [];
groups[key].push(album);
}
const sortedArtists = Object.keys(groups).sort((a, b) => a.localeCompare(b));
return sortedArtists.map(artistName => (
<div key={artistName} className="offline-artist-group">
<h2 className="offline-artist-group-heading">{artistName}</h2>
<div className="album-grid-wrap">
{groups[artistName].map(renderCard)}
</div>
</div>
));
};
const TABS: { id: FilterType; labelKey: string }[] = [
{ id: 'all', labelKey: 'connection.offlineFilterAll' },
{ id: 'album', labelKey: 'connection.offlineFilterAlbums' },
{ id: 'playlist', labelKey: 'connection.offlineFilterPlaylists' },
{ id: 'artist', labelKey: 'connection.offlineFilterArtists' },
];
return (
<div className="offline-library animate-fade-in">
<div className="offline-library-header">
@@ -57,60 +147,30 @@ const buildTracks = (albumId: string) => {
</div>
</div>
{albums.length === 0 ? (
<div className="offline-filter-tabs">
{TABS.map(tab => {
const count = countByType(tab.id);
if (tab.id !== 'all' && count === 0) return null;
return (
<button
key={tab.id}
className={`offline-filter-tab${filter === tab.id ? ' active' : ''}`}
onClick={() => setFilter(tab.id)}
>
{t(tab.labelKey)}
<span className="offline-filter-tab-count">{count}</span>
</button>
);
})}
</div>
{filtered.length === 0 ? (
<div className="empty-state">{t('connection.offlineLibraryEmpty')}</div>
) : filter === 'artist' ? (
renderArtistGroups()
) : (
<div className="album-grid-wrap">
{albums.map(album => {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : '';
const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length;
return (
<div key={album.id} className="album-card card offline-library-card">
<div className="album-card-cover">
{coverUrl ? (
<CachedImage src={coverUrl} cacheKey={cacheKey} alt={`${album.name} Cover`} loading="lazy" />
) : (
<div className="album-card-cover-placeholder">
<HardDriveDownload size={32} />
</div>
)}
<div className="album-card-play-overlay">
<button
className="album-card-details-btn"
onClick={() => handlePlay(album.id)}
aria-label={`${album.name} abspielen`}
>
<Play size={15} fill="currentColor" />
</button>
</div>
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
<div className="offline-library-card-meta">
<button
className="offline-library-enqueue"
onClick={() => handleEnqueue(album.id)}
title="Zur Warteschlange hinzufügen"
>
+ Queue
</button>
<span className="offline-library-tracks">{trackCount} tracks</span>
<button
className="offline-library-delete"
onClick={() => deleteAlbum(album.id, serverId)}
data-tooltip={t('albumDetail.removeOffline')}
data-tooltip-pos="top"
>
<Trash2 size={11} />
</button>
</div>
</div>
</div>
);
})}
{filtered.map(renderCard)}
</div>
)}
</div>