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
+25
View File
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.30.0] - 2026-04-03
### Added
- **Bulk offline download — Playlists & Artist discographies** *(requested by [@Apollosport](https://github.com/Apollosport), [#54](https://github.com/Psychotoxical/psysonic/issues/54))*: Download an entire playlist or a full artist discography for offline use in one click. Progress is tracked per album on the Artist page ("Caching… 2/5 albums").
- **Offline Library filter tabs**: The Offline Library now has four filter tabs — All, Albums, Playlists, and Discographies. The Discographies tab groups albums under their respective artist with section headings.
- **Discord Rich Presence** *(requested by [@Bewenben](https://github.com/Bewenben), [#49](https://github.com/Psychotoxical/psysonic/issues/49))* (opt-in): Psysonic can now update your Discord status with the currently playing track, artist, and a live elapsed timer. Toggle in Settings → General → "Discord Rich Presence".
- **Artist images on Artists page** *(reported by [@Apollosport](https://github.com/Apollosport), [#53](https://github.com/Psychotoxical/psysonic/issues/53))* (opt-in): Artist avatars on the Artists overview can now show the actual artist image from the server instead of the coloured initial. Toggle in Settings → General → "Show artist images". Off by default to preserve performance on large libraries.
- **Image lazy loading**: Cover art and artist images across all pages now load lazily via `IntersectionObserver` (300 px pre-fetch margin), significantly reducing initial page render time on large libraries.
### Fixed
- **Crossfade triggers on manual track skip** *(reported by [@netherguy4](https://github.com/netherguy4), [#35](https://github.com/Psychotoxical/psysonic/issues/35))*: Manually clicking Next/Prev or selecting a track from the queue no longer triggers the crossfade transition. Crossfade now only fires on natural track end.
- **Playlist offline cache showing individual album cards**: Caching a playlist offline previously created one card per album group in the Offline Library. The playlist is now stored as a single cohesive entry.
- **Image cache abort handling**: Aborted image fetches no longer prevented the cached result from being written to IndexedDB, causing covers to reload on every page visit.
### Changed
- **Queue tech strip**: Removed genre from the codec/bitrate overlay strip in the Queue panel — genre strings frequently caused layout overflow.
- **"Save discography offline" label**: The Artist page offline button now reads "Save discography offline" instead of "Download discography" to avoid confusion with a ZIP export.
- **Update toast (Win/Mac)**: The update notification now includes a disclaimer that auto-update is still in development, and always shows a direct GitHub Releases download link alongside the install button as a fallback.
- **Facebook theme overhaul**: Improved grey text contrast, opaque album chip and back button, readable Queue/Lyrics tab labels.
---
## [1.29.0] - 2026-04-02
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.29.0",
"version": "1.30.0",
"private": true,
"scripts": {
"dev": "vite",
+29 -6
View File
@@ -565,7 +565,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid",
"uuid 1.22.0",
]
[[package]]
@@ -986,6 +986,19 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "discord-rich-presence"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75db747ecd252c01bfecaf709b07fcb4c634adf0edb5fed47bc9c3052e7076b"
dependencies = [
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"uuid 0.8.2",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -3470,9 +3483,10 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.28.0"
version = "1.30.0"
dependencies = [
"biquad",
"discord-rich-presence",
"md5",
"reqwest 0.12.28",
"rodio",
@@ -3996,7 +4010,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid",
"uuid 1.22.0",
]
[[package]]
@@ -4906,7 +4920,7 @@ dependencies = [
"thiserror 2.0.18",
"time",
"url",
"uuid",
"uuid 1.22.0",
"walkdir",
]
@@ -5191,7 +5205,7 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
"url",
"urlpattern",
"uuid",
"uuid 1.22.0",
"walkdir",
]
@@ -5704,6 +5718,15 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "uuid"
version = "1.22.0"
@@ -6898,7 +6921,7 @@ dependencies = [
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"uuid 1.22.0",
"windows-sys 0.61.2",
"winnow 0.7.15",
"zbus_macros 5.14.0",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.28.0"
version = "1.30.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -40,3 +40,4 @@ tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }
discord-rich-presence = "0.2"
+3 -1
View File
@@ -1074,6 +1074,7 @@ pub async fn audio_play(
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
manual: bool, // true = user-initiated skip → bypass crossfade, start immediately
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -1137,7 +1138,8 @@ pub async fn audio_play(
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
// Measure how much audio Track A actually has left right now.
+101
View File
@@ -0,0 +1,101 @@
/// Discord Rich Presence integration.
///
/// To enable this feature:
/// 1. Go to https://discord.com/developers/applications and create an application.
/// 2. Copy the Application ID and replace DISCORD_APP_ID below.
/// 3. In the "Rich Presence → Art Assets" tab, upload a PNG named "psysonic"
/// (use the app icon from public/logo.png).
///
/// The commands silently no-op when Discord is not running or the App ID is wrong,
/// so the app always starts cleanly regardless of Discord availability.
use discord_rich_presence::{
activity::{Activity, Assets, Timestamps},
DiscordIpc, DiscordIpcClient,
};
use std::sync::Mutex;
const DISCORD_APP_ID: &str = "1489544859718258779";
pub struct DiscordState(pub Mutex<Option<DiscordIpcClient>>);
impl DiscordState {
pub fn new() -> Self {
DiscordState(Mutex::new(None))
}
}
/// Try to create and connect a fresh IPC client. Returns None silently on failure.
fn try_connect() -> Option<DiscordIpcClient> {
let mut client = DiscordIpcClient::new(DISCORD_APP_ID).ok()?;
client.connect().ok()?;
Some(client)
}
/// Update the Discord Rich Presence activity.
///
/// - `elapsed_secs`: seconds already played. `None` when paused — Discord shows
/// the song/artist without a running timer.
#[tauri::command]
pub fn discord_update_presence(
state: tauri::State<DiscordState>,
title: String,
artist: String,
album: Option<String>,
elapsed_secs: Option<f64>,
) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
// (Re)connect lazily — handles the case where Discord starts after the app.
if guard.is_none() {
match try_connect() {
Some(client) => *guard = Some(client),
None => return Ok(()), // Discord not running — silently skip
}
}
let client = guard.as_mut().unwrap();
let state_text = format!("by {artist}");
let large_text = album.as_deref().unwrap_or("Psysonic");
let assets = Assets::new()
.large_image("psysonic")
.large_text(large_text);
let mut activity = Activity::new()
.details(&title)
.state(&state_text)
.assets(assets);
// Start timestamp: Discord auto-counts up from this point. We back-calculate
// it so the displayed elapsed time matches the actual playback position.
if let Some(elapsed) = elapsed_secs {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let start = now - elapsed.floor() as i64;
activity = activity.timestamps(Timestamps::new().start(start));
}
if client.set_activity(activity).is_err() {
// IPC pipe broke (Discord restarted etc.) — drop the client so the next
// call re-connects.
*guard = None;
}
Ok(())
}
/// Clear the Discord Rich Presence activity (e.g. playback stopped).
#[tauri::command]
pub fn discord_clear_presence(state: tauri::State<DiscordState>) -> Result<(), String> {
let mut guard = state.0.lock().unwrap();
if let Some(client) = guard.as_mut() {
if client.clear_activity().is_err() {
*guard = None;
}
}
Ok(())
}
+4
View File
@@ -2,6 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod audio;
mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
@@ -340,6 +341,7 @@ pub fn run() {
tauri::Builder::default()
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -538,6 +540,8 @@ pub fn run() {
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
discord::discord_update_presence,
discord::discord_clear_presence,
lastfm_request,
download_track_offline,
delete_offline_track,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.29.0",
"version": "1.30.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+12 -4
View File
@@ -337,7 +337,6 @@ export function buildStreamUrl(id: string): string {
u: server?.username ?? '',
t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json',
});
return `${baseUrl}/rest/stream.view?${p.toString()}`;
}
@@ -396,9 +395,18 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
return data.playlist;
}
export async function updatePlaylist(id: string, songIds: string[]): Promise<void> {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
}
}
export async function deletePlaylist(id: string): Promise<void> {
+2 -2
View File
@@ -143,8 +143,8 @@ export default function AlbumHeader({
<button
className="album-detail-cover-btn"
onClick={() => setLightboxOpen(true)}
data-tooltip="Vergrößern"
aria-label={`${info.name} Cover vergrößern`}
data-tooltip={t('albumDetail.enlargeCover')}
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
>
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
</button>
+9 -3
View File
@@ -123,9 +123,15 @@ export default function AppUpdater() {
{state.phase === 'available' && (
<div className="app-updater-actions">
{canInstall && (
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
<>
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
<button className="app-updater-btn-primary" onClick={handleInstall}>
<Download size={12} /> {t('common.updaterInstall')}
</button>
<button className="app-updater-btn-secondary" onClick={handleDownload}>
<Download size={12} /> {t('common.updaterDownload')}
</button>
</>
)}
{isLinuxFallback && (
<button className="app-updater-btn-primary" onClick={handleDownload}>
+24 -6
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { getCachedUrl } from '../utils/imageCache';
interface CachedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -16,16 +16,33 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
const [resolved, setResolved] = useState('');
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
let cancelled = false;
const controller = new AbortController();
setResolved('');
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; };
getCachedUrl(fetchUrl, cacheKey, controller.signal).then(url => {
if (!controller.signal.aborted) setResolved(url);
});
return () => { controller.abort(); };
}, [fetchUrl, cacheKey]);
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
}
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
const resolvedSrc = useCachedUrl(src, cacheKey);
const [inView, setInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const el = imgRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setInView(true); observer.disconnect(); } },
{ rootMargin: '300px' }, // start fetching 300px before entering viewport
);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Pass empty string when not yet in view so useCachedUrl skips the fetch entirely.
const resolvedSrc = useCachedUrl(inView ? src : '', cacheKey);
const [loaded, setLoaded] = useState(false);
// Reset only when the logical image changes (cacheKey), not on fetchUrl→blobUrl
@@ -36,7 +53,8 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
return (
<img
src={resolvedSrc}
ref={imgRef}
src={resolvedSrc || undefined}
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
onLoad={e => { setLoaded(true); onLoad?.(e); }}
{...props}
+1 -2
View File
@@ -371,10 +371,9 @@ export default function QueuePanel() {
{currentTrack && (
<div className="queue-current-track">
{(currentTrack.genre || currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
{(currentTrack.suffix || currentTrack.bitRate || currentTrack.samplingRate || currentTrack.bitDepth) && (
<div className="queue-current-tech">
{[
currentTrack.genre,
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : undefined,
(() => {
+86
View File
@@ -140,6 +140,7 @@ const enTranslation = {
bioModal: 'Artist Biography',
bioClose: 'Close',
ratingLabel: 'Rating',
enlargeCover: 'Enlarge',
},
artistDetail: {
back: 'Back',
@@ -165,6 +166,9 @@ const enTranslation = {
openedInBrowser: 'Opened in browser',
featuredOn: 'Also Featured On',
similarArtists: 'Similar Artists',
cacheOffline: 'Save discography offline',
offlineCached: 'Discography cached',
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
},
favorites: {
title: 'Favorites',
@@ -237,6 +241,8 @@ const enTranslation = {
all: 'All',
gridView: 'Grid view',
listView: 'List view',
imagesOn: 'Artist images on — may increase network and system load',
imagesOff: 'Artist images off — showing initials only',
loadMore: 'Load more',
notFound: 'No artists found.',
albumCount_one: '{{count}} Album',
@@ -275,6 +281,10 @@ const enTranslation = {
offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
offlineFilterAll: 'All',
offlineFilterAlbums: 'Albums',
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Discographies',
retry: 'Retry',
lastfmConnected: 'Last.fm connected as @{{user}}',
lastfmSessionInvalid: 'Session invalid — click to re-connect',
@@ -313,6 +323,7 @@ const enTranslation = {
updaterDownloading: 'Downloading…',
updaterInstalling: 'Installing…',
updaterDownload: 'Download from GitHub',
updaterExperimentalHint: 'Auto-update is still in development',
},
settings: {
title: 'Settings',
@@ -374,8 +385,12 @@ const enTranslation = {
cacheClearWarning: 'This will also remove all offline albums from the library.',
cacheClearConfirm: 'Clear Everything',
cacheClearCancel: 'Cancel',
showArtistImages: 'Show Artist Images',
showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.',
minimizeToTray: 'Minimize to Tray',
minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.',
nowPlayingEnabled: 'Show in Now Playing',
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
downloadsTitle: 'Download Folder',
@@ -672,6 +687,9 @@ const enTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'New suggestions',
addSong: 'Add to playlist',
cacheOffline: 'Cache playlist offline',
offlineCached: 'Playlist cached',
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
}
};
@@ -814,6 +832,7 @@ const deTranslation = {
bioModal: 'Künstler-Biografie',
bioClose: 'Schließen',
ratingLabel: 'Bewertung',
enlargeCover: 'Vergrößern',
},
artistDetail: {
back: 'Zurück',
@@ -839,6 +858,9 @@ const deTranslation = {
openedInBrowser: 'Im Browser geöffnet',
featuredOn: 'Auch enthalten auf',
similarArtists: 'Ähnliche Künstler',
cacheOffline: 'Diskografie offline speichern',
offlineCached: 'Diskografie gecacht',
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
},
favorites: {
title: 'Favoriten',
@@ -911,6 +933,8 @@ const deTranslation = {
all: 'Alle',
gridView: 'Gitteransicht',
listView: 'Listenansicht',
imagesOn: 'Künstlerbilder aktiv — kann Netzwerk- und Systemlast erhöhen',
imagesOff: 'Künstlerbilder deaktiviert — zeigt nur Initialen',
loadMore: 'Mehr laden',
notFound: 'Keine Künstler gefunden.',
albumCount_one: '{{count}} Album',
@@ -949,6 +973,10 @@ const deTranslation = {
offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.',
offlineAlbumCount: '{{n}} Album',
offlineAlbumCount_plural: '{{n}} Alben',
offlineFilterAll: 'Alle',
offlineFilterAlbums: 'Alben',
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Diskografien',
retry: 'Erneut versuchen',
lastfmConnected: 'Last.fm verbunden als @{{user}}',
lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden',
@@ -987,6 +1015,7 @@ const deTranslation = {
updaterDownloading: 'Wird geladen…',
updaterInstalling: 'Wird installiert…',
updaterDownload: 'Von GitHub herunterladen',
updaterExperimentalHint: 'Automatische Updates sind noch in Entwicklung',
},
settings: {
title: 'Einstellungen',
@@ -1048,8 +1077,12 @@ const deTranslation = {
cacheClearWarning: 'Alle Offline-Alben werden damit aus der Bibliothek entfernt.',
cacheClearConfirm: 'Alles löschen',
cacheClearCancel: 'Abbrechen',
showArtistImages: 'Künstlerbilder anzeigen',
showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.',
minimizeToTray: 'Im Tray minimieren',
minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.',
nowPlayingEnabled: 'Im Livefenster anzeigen',
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
downloadsTitle: 'Download-Ordner',
@@ -1346,6 +1379,9 @@ const deTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Neue Vorschläge',
addSong: 'Zur Playlist hinzufügen',
cacheOffline: 'Playlist offline speichern',
offlineCached: 'Playlist gecacht',
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
}
};
@@ -1488,6 +1524,7 @@ const frTranslation = {
bioModal: 'Biographie de l\'artiste',
bioClose: 'Fermer',
ratingLabel: 'Note',
enlargeCover: 'Agrandir',
},
artistDetail: {
back: 'Retour',
@@ -1513,6 +1550,9 @@ const frTranslation = {
openedInBrowser: 'Ouvert dans le navigateur',
featuredOn: 'Également sur',
similarArtists: 'Artistes similaires',
cacheOffline: 'Enregistrer la discographie hors ligne',
offlineCached: 'Discographie en cache',
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
},
favorites: {
title: 'Favoris',
@@ -1585,6 +1625,8 @@ const frTranslation = {
all: 'Tous',
gridView: 'Vue en grille',
listView: 'Vue en liste',
imagesOn: 'Images d\'artistes activées — peut augmenter la charge réseau et système',
imagesOff: 'Images d\'artistes désactivées — affichage des initiales uniquement',
loadMore: 'Charger plus',
notFound: 'Aucun artiste trouvé.',
albumCount_one: '{{count}} album',
@@ -1623,6 +1665,10 @@ const frTranslation = {
offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
offlineFilterAll: 'Tout',
offlineFilterAlbums: 'Albums',
offlineFilterPlaylists: 'Playlists',
offlineFilterArtists: 'Discographies',
retry: 'Réessayer',
lastfmConnected: 'Last.fm connecté en tant que @{{user}}',
lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter',
@@ -1661,6 +1707,7 @@ const frTranslation = {
updaterDownloading: 'Téléchargement…',
updaterInstalling: 'Installation…',
updaterDownload: 'Télécharger depuis GitHub',
updaterExperimentalHint: 'La mise à jour automatique est encore en développement',
},
settings: {
title: 'Paramètres',
@@ -1722,8 +1769,12 @@ const frTranslation = {
cacheClearWarning: 'Cela supprimera aussi tous les albums hors ligne de la bibliothèque.',
cacheClearConfirm: 'Tout supprimer',
cacheClearCancel: 'Annuler',
showArtistImages: 'Afficher les images d\'artistes',
showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.',
minimizeToTray: 'Réduire dans la barre système',
minimizeToTrayDesc: 'Lors de la fermeture, Psysonic continue de fonctionner dans la barre système au lieu de se fermer.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.',
nowPlayingEnabled: 'Afficher dans la fenêtre live',
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
downloadsTitle: 'Dossier de téléchargement',
@@ -2020,6 +2071,9 @@ const frTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Nouvelles suggestions',
addSong: 'Ajouter à la playlist',
cacheOffline: 'Mettre la playlist hors ligne',
offlineCached: 'Playlist en cache',
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
}
};
@@ -2162,6 +2216,7 @@ const nlTranslation = {
bioModal: 'Artiest biografie',
bioClose: 'Sluiten',
ratingLabel: 'Beoordeling',
enlargeCover: 'Vergroten',
},
artistDetail: {
back: 'Terug',
@@ -2187,6 +2242,9 @@ const nlTranslation = {
openedInBrowser: 'Geopend in browser',
featuredOn: 'Ook te vinden op',
similarArtists: 'Vergelijkbare artiesten',
cacheOffline: 'Discografie offline opslaan',
offlineCached: 'Discografie gecached',
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
},
favorites: {
title: 'Favorieten',
@@ -2259,6 +2317,8 @@ const nlTranslation = {
all: 'Alle',
gridView: 'Rasterweergave',
listView: 'Lijstweergave',
imagesOn: 'Artiestafbeeldingen aan — kan netwerk- en systeembelasting verhogen',
imagesOff: 'Artiestafbeeldingen uit — toont alleen initialen',
loadMore: 'Meer laden',
notFound: 'Geen artiesten gevonden.',
albumCount_one: '{{count}} album',
@@ -2297,6 +2357,10 @@ const nlTranslation = {
offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".',
offlineAlbumCount: '{{n}} album',
offlineAlbumCount_plural: '{{n}} albums',
offlineFilterAll: 'Alles',
offlineFilterAlbums: 'Albums',
offlineFilterPlaylists: 'Afspeellijsten',
offlineFilterArtists: 'Discografieën',
retry: 'Opnieuw proberen',
lastfmConnected: 'Last.fm verbonden als @{{user}}',
lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden',
@@ -2335,6 +2399,7 @@ const nlTranslation = {
updaterDownloading: 'Downloaden…',
updaterInstalling: 'Installeren…',
updaterDownload: 'Downloaden van GitHub',
updaterExperimentalHint: 'Automatisch bijwerken is nog in ontwikkeling',
},
settings: {
title: 'Instellingen',
@@ -2396,8 +2461,12 @@ const nlTranslation = {
cacheClearWarning: 'Dit verwijdert ook alle offline albums uit de bibliotheek.',
cacheClearConfirm: 'Alles wissen',
cacheClearCancel: 'Annuleren',
showArtistImages: 'Artiestafbeeldingen weergeven',
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.',
minimizeToTray: 'Minimaliseren naar systeemvak',
minimizeToTrayDesc: 'Bij het sluiten van het venster blijft Psysonic actief in het systeemvak in plaats van af te sluiten.',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.',
nowPlayingEnabled: 'Weergeven in live-venster',
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
downloadsTitle: 'Downloadmap',
@@ -2694,6 +2763,9 @@ const nlTranslation = {
titleBadge: 'Playlist',
refreshSuggestions: 'Nieuwe suggesties',
addSong: 'Toevoegen aan playlist',
cacheOffline: 'Playlist offline opslaan',
offlineCached: 'Playlist gecached',
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
}
};
@@ -2836,6 +2908,7 @@ const zhTranslation = {
bioModal: '艺术家简介',
bioClose: '关闭',
ratingLabel: '评分',
enlargeCover: '放大',
},
artistDetail: {
back: '返回',
@@ -2861,6 +2934,9 @@ const zhTranslation = {
openedInBrowser: '已在浏览器中打开',
featuredOn: '还出现在',
similarArtists: '相似艺术家',
cacheOffline: '离线保存全部专辑',
offlineCached: '全部专辑已缓存',
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
},
favorites: {
title: '收藏夹',
@@ -2933,6 +3009,8 @@ const zhTranslation = {
all: '全部',
gridView: '网格视图',
listView: '列表视图',
imagesOn: '艺术家图片已开启 — 可能增加网络和系统负载',
imagesOff: '艺术家图片已关闭 — 仅显示首字母',
loadMore: '加载更多',
notFound: '未找到艺术家。',
albumCount_one: '{{count}} 张专辑',
@@ -3009,6 +3087,7 @@ const zhTranslation = {
updaterDownloading: '下载中…',
updaterInstalling: '安装中…',
updaterDownload: '从 GitHub 下载',
updaterExperimentalHint: '自动更新功能仍在开发中',
},
settings: {
title: '设置',
@@ -3070,8 +3149,12 @@ const zhTranslation = {
cacheClearWarning: '这将同时移除音乐库中的所有离线专辑。',
cacheClearConfirm: '全部清除',
cacheClearCancel: '取消',
showArtistImages: '显示艺术家图片',
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
minimizeToTray: '最小化到托盘',
minimizeToTrayDesc: '关闭窗口时,Psysonic 将继续在系统托盘中运行,而不是退出。',
discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
nowPlayingEnabled: '在实时窗口中显示',
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
downloadsTitle: '下载文件夹',
@@ -3368,6 +3451,9 @@ const zhTranslation = {
titleBadge: '播放列表',
refreshSuggestions: '新建议',
addSong: '添加到播放列表',
cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存',
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
}
};
+3
View File
@@ -32,6 +32,7 @@ export default function AlbumDetail() {
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -201,12 +202,14 @@ const handleEnqueueAll = () => {
const next = new Set(starredSongs);
if (wasStarred) next.delete(song.id); else next.add(song.id);
setStarredSongs(next);
setStarredOverride(song.id, !wasStarred);
try {
if (wasStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
setStarredOverride(song.id, wasStarred);
}
};
+27 -1
View File
@@ -4,9 +4,11 @@ import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, getAlbum, sear
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio } from 'lucide-react';
import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveDownload, Check } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
@@ -60,6 +62,8 @@ export default function ArtistDetail() {
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const currentTrack = usePlayerStore(state => state.currentTrack);
const isPlaying = usePlayerStore(state => state.isPlaying);
const { downloadArtist, bulkProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
useEffect(() => {
if (!id) return;
@@ -341,6 +345,28 @@ export default function ArtistDetail() {
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}
</button>
{albums.length > 0 && (() => {
const progress = id ? bulkProgress[id] : undefined;
const isDone = progress && progress.done === progress.total;
const isDownloading = progress && !isDone;
return (
<button
className="btn btn-surface"
disabled={!!isDownloading}
onClick={() => { if (id && artist) downloadArtist(id, artist.name, activeServerId); }}
data-tooltip={isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
: isDone ? <Check size={16} /> : <HardDriveDownload size={16} />}
{isDownloading
? t('artistDetail.offlineDownloading', { done: progress.done, total: progress.total })
: isDone ? t('artistDetail.offlineCached') : t('artistDetail.cacheOffline')}
</button>
);
})()}
</div>
</div>
</div>
+98 -55
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist } from '../api/subsonic';
import { LayoutGrid, List } from 'lucide-react';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
const ALL_SENTINEL = 'ALL';
@@ -23,12 +25,52 @@ function nameColor(name: string): string {
}
function nameInitial(name: string): string {
// Skip leading non-letter chars (punctuation, numbers, brackets, …)
const letter = name.match(/[a-zA-ZÀ-ÖØ-öø-ÿ]/)?.[0];
// \p{L} matches any Unicode letter — covers cyrillic, arabic, CJK, etc.
const letter = name.match(/\p{L}/u)?.[0];
if (letter) return letter.toUpperCase();
// Fallback: first alphanumeric (e.g. "1349")
const alnum = name.match(/[a-zA-Z0-9]/)?.[0];
return alnum?.toUpperCase() ?? '?';
const alnum = name.match(/[0-9]/)?.[0];
return alnum ?? '?';
}
function ArtistCardAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
const color = nameColor(artist.name);
if (showImages && artist.coverArt) {
return (
<div className="artist-card-avatar">
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 300)}
cacheKey={coverArtCacheKey(artist.coverArt, 300)}
alt={artist.name}
/>
</div>
);
}
return (
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
);
}
function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) {
const color = nameColor(artist.name);
if (showImages && artist.coverArt) {
return (
<div className="artist-avatar">
<CachedImage
src={buildCoverArtUrl(artist.coverArt, 64)}
cacheKey={coverArtCacheKey(artist.coverArt, 64)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }}
/>
</div>
);
}
return (
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
);
}
export default function Artists() {
@@ -42,6 +84,8 @@ export default function Artists() {
const [visibleCount, setVisibleCount] = useState(50);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
@@ -101,6 +145,15 @@ export default function Artists() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
@@ -146,30 +199,25 @@ export default function Artists() {
{!loading && viewMode === 'grid' && (
<div className="album-grid-wrap">
{visible.map(artist => {
const color = nameColor(artist.name);
return (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
>
<div className="artist-card-avatar artist-card-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
{visible.map(artist => (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
>
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-card-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
);
})}
</div>
))}
</div>
)}
@@ -179,31 +227,26 @@ export default function Artists() {
<div key={letter} style={{ marginBottom: '1.5rem' }}>
<h3 className="letter-heading">{letter}</h3>
<div className="artist-list">
{groups[letter].map(artist => {
const color = nameColor(artist.name);
return (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
id={`artist-${artist.id}`}
>
<div className="artist-avatar artist-avatar-initial" style={{ borderColor: color }}>
<span style={{ color }}>{nameInitial(artist.name)}</span>
</div>
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
);
})}
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}}
id={`artist-${artist.id}`}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
<div className="artist-name">{artist.name}</div>
{artist.albumCount != null && (
<div className="artist-meta">{t('artists.albumCount', { count: artist.albumCount })}</div>
)}
</div>
</button>
))}
</div>
</div>
))}
+10 -6
View File
@@ -19,10 +19,13 @@ export default function Favorites() {
const { playTrack, enqueue } = usePlayerStore();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const psyDrag = useDragDrop();
function removeSong(id: string) {
unstar(id, 'song').catch(() => {});
setStarredOverride(id, false);
setSongs(prev => prev.filter(s => s.id !== id));
}
const openContextMenu = usePlayerStore(s => s.openContextMenu);
@@ -47,7 +50,8 @@ export default function Favorites() {
);
}
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || songs.length > 0;
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
@@ -67,14 +71,14 @@ export default function Favorites() {
<AlbumRow title={t('favorites.albums')} albums={albums} />
)}
{songs.length > 0 && (
{visibleSongs.length > 0 && (
<section className="album-row-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button
className="btn btn-primary"
onClick={() => {
const tracks = songs.map(songToTrack);
const tracks = visibleSongs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
@@ -84,7 +88,7 @@ export default function Favorites() {
<button
className="btn btn-surface"
onClick={() => {
const tracks = songs.map(songToTrack);
const tracks = visibleSongs.map(songToTrack);
enqueue(tracks);
}}
>
@@ -100,7 +104,7 @@ export default function Favorites() {
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div />
</div>
{songs.map((song, i) => {
{visibleSongs.map((song, i) => {
const track = songToTrack(song);
return (
<div
@@ -109,7 +113,7 @@ export default function Favorites() {
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, songs.map(songToTrack));
playTrack(track, visibleSongs.map(songToTrack));
}}
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
role="row"
+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>
+35 -9
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart } from 'lucide-react';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import {
getPlaylist, updatePlaylist, search, setRating, star, unstar,
@@ -8,6 +8,8 @@ import {
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
@@ -54,9 +56,11 @@ export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying } = usePlayerStore();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore();
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
@@ -92,9 +96,10 @@ export default function PlaylistDetail() {
const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(songs.map(s => s.id)));
const bulkRemove = () => {
const prevCount = songs.length;
const next = songs.filter(s => !selectedIds.has(s.id));
setSongs(next);
savePlaylist(next);
savePlaylist(next, prevCount);
setSelectedIds(new Set());
};
@@ -204,11 +209,11 @@ export default function PlaylistDetail() {
}, [playlist?.id]);
// ── Save ──────────────────────────────────────────────────────
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[]) => {
const savePlaylist = useCallback(async (updatedSongs: SubsonicSong[], prevCount = 0) => {
if (!id) return;
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id));
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
if (id) touchPlaylist(id);
} catch {}
setSaving(false);
@@ -216,9 +221,10 @@ export default function PlaylistDetail() {
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const prevCount = songs.length;
const next = songs.filter((_, i) => i !== idx);
setSongs(next);
savePlaylist(next);
savePlaylist(next, prevCount);
};
// ── Add ───────────────────────────────────────────────────────
@@ -239,12 +245,13 @@ export default function PlaylistDetail() {
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const isStarred = starredSongs.has(song.id);
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
setStarredSongs(prev => {
const next = new Set(prev);
isStarred ? next.delete(song.id) : next.add(song.id);
return next;
});
setStarredOverride(song.id, !isStarred);
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
};
@@ -424,6 +431,25 @@ export default function PlaylistDetail() {
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
{songs.length > 0 && id && (() => {
const isDownloading = isAlbumDownloading(id);
const isCached = isAlbumDownloaded(id, activeServerId);
const progress = isDownloading ? getAlbumProgress(id) : null;
return (
<button
className="btn btn-ghost"
disabled={isDownloading}
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
data-tooltip={isDownloading
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
</button>
);
})()}
</div>
</div>
</div>
@@ -599,9 +625,9 @@ export default function PlaylistDetail() {
<button
className="btn btn-ghost track-star-btn"
onClick={e => handleToggleStar(song, e)}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
+145 -140
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { Play, Star, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
@@ -28,6 +28,10 @@ export default function RandomMix() {
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const psyDrag = useDragDrop();
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
@@ -105,11 +109,12 @@ export default function RandomMix() {
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const currentlyStarred = starredSongs.has(song.id);
const currentlyStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
const nextStarred = new Set(starredSongs);
if (currentlyStarred) nextStarred.delete(song.id);
else nextStarred.add(song.id);
setStarredSongs(nextStarred);
setStarredOverride(song.id, !currentlyStarred);
try {
if (currentlyStarred) await unstar(song.id, 'song');
@@ -117,6 +122,7 @@ export default function RandomMix() {
} catch (err) {
console.error('Failed to toggle song star', err);
setStarredSongs(new Set(starredSongs));
setStarredOverride(song.id, currentlyStarred);
}
};
@@ -319,19 +325,28 @@ export default function RandomMix() {
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
<div></div>
<div>{t('randomMix.trackTitle')}</div>
<div>{t('randomMix.trackArtist')}</div>
<div>{t('randomMix.trackAlbum')}</div>
<div className="col-center">{t('randomMix.trackFavorite')}</div>
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{genreMixSongs.map(song => {
const track = songToTrack(song);
const queueSongs = genreMixSongs.map(songToTrack);
const isCurrentTrack = currentTrack?.id === song.id;
const artist = song.artist;
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const isArtistJustAdded = addedArtist === artist;
return (
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => playTrack(track, genreMixSongs.map(songToTrack))} role="row"
<div
key={song.id}
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
role="row"
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
onMouseDown={e => {
if (e.button !== 0) return;
@@ -349,21 +364,51 @@ export default function RandomMix() {
document.addEventListener('mouseup', onUp);
}}
>
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(track, genreMixSongs.map(songToTrack)); }}>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
<span className="track-duration" style={{ textAlign: 'right' }}>{formatDuration(song.duration)}</span>
</div>
);
})}
</div>
)}
</div>
)}
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
{isCurrentTrack && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell">
{artist ? (
<button
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
onClick={() => {
if (isArtistBlocked) return;
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
setAddedArtist(artist);
setTimeout(() => setAddedArtist(null), 1500);
}
}}
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>{artist}</button>
) : <span className="track-artist"></span>}
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => toggleSongStar(song, e)}
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
<div className="track-duration">{formatDuration(song.duration)}</div>
</div>
);
})}
</div>
)}
</div>
)}
{!selectedGenre && (loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -371,26 +416,37 @@ export default function RandomMix() {
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}>
<span></span>
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
<div></div>
<div>{t('randomMix.trackTitle')}</div>
<div>{t('randomMix.trackArtist')}</div>
<div>{t('randomMix.trackAlbum')}</div>
<div data-tooltip={t('randomMix.genreClickHint')} data-tooltip-wrap style={{ cursor: 'help' }}>
{t('randomMix.trackGenre')} <span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 13 }}></span>
</span>
<span style={{ textAlign: 'center' }}>{t('randomMix.trackFavorite')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
<div className="col-center">{t('randomMix.trackFavorite')}</div>
<div className="col-center">{t('randomMix.trackDuration')}</div>
</div>
{filteredSongs.map((song) => {
const track = songToTrack(song);
const queueSongs = filteredSongs.map(songToTrack);
const isCurrentTrack = currentTrack?.id === song.id;
const artist = song.artist;
const genre = song.genre;
const isArtistBlocked = !!artist && customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const isArtistJustAdded = addedArtist === artist;
const isGenreBlocked = !!genre && (
AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))
);
const isGenreJustAdded = addedGenre === genre;
return (
<div
key={song.id}
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => playTrack(track, filteredSongs.map(songToTrack))}
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
role="row"
onContextMenu={e => {
e.preventDefault();
@@ -413,124 +469,73 @@ export default function RandomMix() {
document.addEventListener('mouseup', onUp);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(songToTrack(song), filteredSongs.map(songToTrack)); }}
data-tooltip={t('randomMix.play')}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-num" style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, queueSongs); }}>
<span style={{ color: isCurrentTrack ? 'var(--accent)' : 'var(--text-muted)' }}>
{isCurrentTrack && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: <Play size={13} fill="currentColor" />}
</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
{(() => {
const artist = song.artist;
if (!artist) return <span className="track-artist"></span>;
const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedArtist === artist;
return (
<div className="track-artist-cell">
{artist ? (
<button
className="btn btn-ghost track-artist"
style={{
fontSize: 'inherit',
padding: '1px 6px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'transparent',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-secondary)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
textAlign: 'left',
}}
className={`rm-artist-btn${isArtistBlocked ? ' is-blocked' : isArtistJustAdded ? ' just-added' : ''}`}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
if (isArtistBlocked) return;
if (!customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase()))) {
setCustomGenreBlacklist([...customGenreBlacklist, artist]);
setAddedArtist(artist);
setTimeout(() => setAddedArtist(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.artistBlocked') : justAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>
{artist}
</button>
);
})()}
</div>
data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')}
>{artist}</button>
) : <span className="track-artist"></span>}
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{song.album ?? '—'}</span>
</div>
{(() => {
const genre = song.genre;
if (!genre) return <div style={{ fontSize: 12, color: 'var(--text-muted)' }}></div>;
const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) ||
customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
const justAdded = addedGenre === genre;
return (
<div>
{genre ? (
<button
className={`rm-genre-chip${isGenreBlocked ? ' is-blocked' : isGenreJustAdded ? ' just-added' : ''}`}
onClick={() => {
if (isGenreBlocked) return;
if (!customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()))) {
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
setAddedGenre(genre);
setTimeout(() => setAddedGenre(null), 1500);
}
}}
data-tooltip={isGenreBlocked ? t('randomMix.genreBlocked') : isGenreJustAdded ? t('randomMix.genreAddedToBlacklist') : t('randomMix.genreClickHint')}
>{genre}</button>
) : <span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>}
</div>
<div className="track-star-cell">
<button
className="btn btn-ghost"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
background: isBlocked ? 'color-mix(in srgb, var(--danger) 15%, transparent)' : justAdded ? 'color-mix(in srgb, var(--accent) 15%, transparent)' : 'var(--bg-hover)',
color: isBlocked ? 'var(--danger)' : justAdded ? 'var(--accent)' : 'var(--text-muted)',
border: 'none',
cursor: isBlocked ? 'default' : 'pointer',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 'auto',
minHeight: 'unset',
}}
onClick={() => {
if (isBlocked) return;
const already = customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase()));
if (!already) {
setCustomGenreBlacklist([...customGenreBlacklist, genre]);
setAddedGenre(genre);
setTimeout(() => setAddedGenre(null), 1500);
}
}}
data-tooltip={isBlocked ? t('randomMix.genreBlocked') : justAdded ? t('randomMix.genreAddedToBlacklist') : genre}
className="btn btn-ghost track-star-btn"
onClick={e => toggleSongStar(song, e)}
data-tooltip={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
{genre}
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
);
})()}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={(e) => toggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('randomMix.favoriteRemove') : t('randomMix.favoriteAdd')}
style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button>
<div className="track-duration">{formatDuration(song.duration)}</div>
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)}
</span>
</div>
);
})}
</div>
))}
);
})}
</div>
))}
</div>
);
+22
View File
@@ -1008,6 +1008,28 @@ export default function Settings() {
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
+8
View File
@@ -34,7 +34,9 @@ interface AuthState {
crossfadeSecs: number;
gaplessEnabled: boolean;
infiniteQueueEnabled: boolean;
showArtistImages: boolean;
minimizeToTray: boolean;
discordRichPresence: boolean;
nowPlayingEnabled: boolean;
showChangelogOnUpdate: boolean;
lastSeenChangelogVersion: string;
@@ -68,7 +70,9 @@ interface AuthState {
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
setShowChangelogOnUpdate: (v: boolean) => void;
setLastSeenChangelogVersion: (v: string) => void;
@@ -103,7 +107,9 @@ export const useAuthStore = create<AuthState>()(
crossfadeSecs: 3,
gaplessEnabled: false,
infiniteQueueEnabled: false,
showArtistImages: false,
minimizeToTray: false,
discordRichPresence: false,
nowPlayingEnabled: false,
showChangelogOnUpdate: true,
lastSeenChangelogVersion: '',
@@ -170,7 +176,9 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }),
setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }),
+46 -3
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
import type { SubsonicSong } from '../api/subsonic';
export interface OfflineTrackMeta {
@@ -33,6 +33,7 @@ export interface OfflineAlbumMeta {
coverArt?: string;
year?: number;
trackIds: string[];
type?: 'album' | 'playlist' | 'artist';
}
export interface DownloadJob {
@@ -49,6 +50,8 @@ interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
jobs: DownloadJob[];
/** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */
bulkProgress: Record<string, { done: number; total: number }>;
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
@@ -62,7 +65,10 @@ interface OfflineState {
year: number | undefined,
songs: SubsonicSong[],
serverId: string,
type?: 'album' | 'playlist' | 'artist',
) => Promise<void>;
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
deleteAlbum: (albumId: string, serverId: string) => Promise<void>;
clearAll: (serverId: string) => Promise<void>;
getAlbumProgress: (albumId: string) => { done: number; total: number } | null;
@@ -74,6 +80,7 @@ export const useOfflineStore = create<OfflineState>()(
tracks: {},
albums: {},
jobs: [],
bulkProgress: {},
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
@@ -110,7 +117,7 @@ export const useOfflineStore = create<OfflineState>()(
return { done, total: albumJobs.length };
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId) => {
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
const CONCURRENCY = 2;
const trackIds = songs.map(s => s.id);
@@ -118,7 +125,7 @@ export const useOfflineStore = create<OfflineState>()(
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds },
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
},
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
@@ -211,6 +218,42 @@ export const useOfflineStore = create<OfflineState>()(
}, 2500);
},
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
// Deduplicate songs (a track can appear multiple times in a playlist).
const seen = new Set<string>();
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
// Store the entire playlist as one virtual album entry so the Offline Library
// shows a single card for the playlist rather than one card per album.
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
},
downloadArtist: async (artistId, artistName, serverId) => {
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
try {
const res = await getArtist(artistId);
albums = res.albums;
} catch { return; }
set(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
}));
for (let i = 0; i < albums.length; i++) {
const album = albums[i];
try {
const { songs } = await getAlbum(album.id);
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
} catch { /* skip failed album */ }
set(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
}));
}
setTimeout(() => {
set(state => {
const { [artistId]: _removed, ...rest } = state.bulkProgress;
return { bulkProgress: rest };
});
}, 3000);
},
deleteAlbum: async (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album) return;
+56 -12
View File
@@ -73,12 +73,12 @@ interface PlayerState {
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
playTrack: (track: Track, queue?: Track[]) => void;
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
pause: () => void;
resume: () => void;
stop: () => void;
togglePlay: () => void;
next: () => void;
next: (manual?: boolean) => void;
previous: () => void;
seek: (progress: number) => void;
setVolume: (v: number) => void;
@@ -267,9 +267,9 @@ function handleAudioEnded() {
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
setTimeout(() => {
if (repeatMode === 'one' && currentTrack) {
usePlayerStore.getState().playTrack(currentTrack, queue);
usePlayerStore.getState().playTrack(currentTrack, queue, false);
} else {
usePlayerStore.getState().next();
usePlayerStore.getState().next(false);
}
}, 150);
}
@@ -341,7 +341,7 @@ function handleAudioError(message: string) {
usePlayerStore.setState({ isPlaying: false });
setTimeout(() => {
if (playGeneration !== gen) return;
usePlayerStore.getState().next();
usePlayerStore.getState().next(false);
}, 1500);
}
@@ -427,9 +427,50 @@ export function initAudioListeners(): () => void {
}
});
// ── Discord Rich Presence sync ────────────────────────────────────────────
// Updates on track change or play/pause toggle. No per-tick updates needed —
// Discord auto-counts up the elapsed timer from the start_timestamp we set.
let discordPrevTrackId: string | null = null;
let discordPrevIsPlaying: boolean | null = null;
function syncDiscord() {
const { currentTrack, isPlaying, currentTime } = usePlayerStore.getState();
const { discordRichPresence } = useAuthStore.getState();
if (!discordRichPresence || !currentTrack) {
if (discordPrevTrackId !== null) {
discordPrevTrackId = null;
discordPrevIsPlaying = null;
invoke('discord_clear_presence').catch(() => {});
}
return;
}
const trackChanged = currentTrack.id !== discordPrevTrackId;
const playingChanged = isPlaying !== discordPrevIsPlaying;
if (!trackChanged && !playingChanged) return;
discordPrevTrackId = currentTrack.id;
discordPrevIsPlaying = isPlaying;
invoke('discord_update_presence', {
title: currentTrack.title,
artist: currentTrack.artist ?? 'Unknown Artist',
album: currentTrack.album ?? null,
// Pass elapsed when playing so Discord shows a live running timer.
// Pass null when paused — Discord shows the song/artist without a timer.
elapsedSecs: isPlaying ? currentTime : null,
}).catch(() => {});
}
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
const unsubDiscordAuth = useAuthStore.subscribe(syncDiscord);
return () => {
unsubAuth();
unsubMpris();
unsubDiscordPlayer();
unsubDiscordAuth();
pending.forEach(p => p.then(unlisten => unlisten()));
};
}
@@ -535,7 +576,7 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
playTrack: (track, queue, manual = true) => {
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) {
@@ -576,13 +617,14 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: track.duration,
replayGainDb,
replayGainPeak,
manual,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false });
setTimeout(() => {
if (playGeneration !== gen) return;
get().next();
get().next(false);
}, 500);
});
@@ -641,6 +683,7 @@ export const usePlayerStore = create<PlayerState>()(
volume: vol,
durationHint: trackToPlay.duration,
replayGainDb: replayGainDbCold,
manual: false,
replayGainPeak: replayGainPeakCold,
}).then(() => {
if (playGeneration === gen && currentTime > 1) {
@@ -668,6 +711,7 @@ export const usePlayerStore = create<PlayerState>()(
durationHint: currentTrack.duration,
replayGainDb: replayGainDbCold,
replayGainPeak: replayGainPeakCold,
manual: false,
}).catch((err: unknown) => {
if (playGeneration !== gen) return;
console.error('[psysonic] audio_play (cold resume) failed:', err);
@@ -687,11 +731,11 @@ export const usePlayerStore = create<PlayerState>()(
},
// ── next / previous ──────────────────────────────────────────────────────
next: () => {
next: (manual = true) => {
const { queue, queueIndex, repeatMode, currentTrack } = get();
const nextIdx = queueIndex + 1;
if (nextIdx < queue.length) {
get().playTrack(queue[nextIdx], queue);
get().playTrack(queue[nextIdx], queue, manual);
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
// so the queue never runs dry without a visible loading pause.
const { infiniteQueueEnabled } = useAuthStore.getState();
@@ -735,7 +779,7 @@ export const usePlayerStore = create<PlayerState>()(
}
}
} else if (repeatMode === 'all' && queue.length > 0) {
get().playTrack(queue[0], queue);
get().playTrack(queue[0], queue, manual);
} else {
// Queue exhausted. Check radio first (independent of infinite queue setting),
// then infinite queue, then stop.
@@ -755,7 +799,7 @@ export const usePlayerStore = create<PlayerState>()(
if (fresh.length > 0) {
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...fresh];
get().playTrack(fresh[0], newQueue);
get().playTrack(fresh[0], newQueue, false);
} else {
invoke('audio_stop').catch(console.error);
isAudioPaused = false;
@@ -786,7 +830,7 @@ export const usePlayerStore = create<PlayerState>()(
const newTracks: Track[] = songs.map(s => ({ ...songToTrack(s), autoAdded: true }));
const currentQueue = get().queue;
const newQueue = [...currentQueue, ...newTracks];
get().playTrack(newTracks[0], newQueue);
get().playTrack(newTracks[0], newQueue, false);
}).catch(() => {
infiniteQueueFetching = false;
invoke('audio_stop').catch(console.error);
+121
View File
@@ -723,6 +723,63 @@
color: var(--color-error, #e05050);
}
/* Filter tabs */
.offline-filter-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.offline-filter-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: none;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.offline-filter-tab:hover {
color: var(--text-primary);
border-color: var(--accent);
}
.offline-filter-tab.active {
background: var(--accent);
color: var(--ctp-base);
border-color: var(--accent);
font-weight: 600;
}
.offline-filter-tab-count {
font-size: 11px;
opacity: 0.75;
}
.offline-filter-tab.active .offline-filter-tab-count {
opacity: 0.85;
}
/* Artist discography groups */
.offline-artist-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.offline-artist-group-heading {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.album-detail {
display: flex;
flex-direction: column;
@@ -1496,6 +1553,70 @@
min-height: unset;
}
/* ── Random Mix — clickable artist name ── */
.rm-artist-btn {
background: none;
border: none;
padding: 0;
margin: 0;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
text-align: left;
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: transparent;
transition: color 0.15s, text-decoration-color 0.15s;
}
.rm-artist-btn:hover {
color: var(--accent);
text-decoration-color: var(--accent);
}
.rm-artist-btn.is-blocked {
color: var(--danger);
text-decoration-color: var(--danger);
cursor: default;
}
.rm-artist-btn.just-added {
color: var(--accent);
text-decoration-color: var(--accent);
}
/* ── Random Mix — genre pill chip ── */
.rm-genre-chip {
display: inline-block;
background: var(--bg-hover);
border: none;
border-radius: var(--radius-sm);
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
transition: background 0.15s, color 0.15s;
}
.rm-genre-chip:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.rm-genre-chip.is-blocked {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
cursor: default;
}
.rm-genre-chip.just-added {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
/* ─ Modal ─ */
.modal-overlay {
position: fixed;
+24
View File
@@ -293,6 +293,15 @@
.app-updater-actions {
padding-left: 19px;
margin-top: 2px;
display: flex;
flex-direction: column;
gap: 4px;
}
.app-updater-hint {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
margin: 0 0 2px;
}
.app-updater-btn-primary {
display: inline-flex;
@@ -309,6 +318,21 @@
transition: opacity var(--transition-fast);
}
.app-updater-btn-primary:hover { opacity: 1; }
.app-updater-btn-secondary {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
padding: 0;
opacity: 0.75;
transition: opacity var(--transition-fast);
}
.app-updater-btn-secondary:hover { opacity: 1; color: var(--text-primary); }
.app-updater-progress-wrap {
display: flex;
align-items: center;
+35 -6
View File
@@ -8384,11 +8384,11 @@ input[type="range"]:hover::-webkit-slider-thumb {
--ctp-surface1: #D8DADF;
--ctp-surface2: #CED0D4;
--ctp-overlay0: #B0B3B8;
--ctp-overlay1: #65676B;
--ctp-overlay2: #4B4C4F;
--ctp-overlay1: #4B4C4F;
--ctp-overlay2: #3A3B3D;
--ctp-text: #050505;
--ctp-subtext1: #65676B;
--ctp-subtext0: #8A8D91;
--ctp-subtext1: #44464A;
--ctp-subtext0: #65676B;
--ctp-mauve: #1877F2;
--ctp-lavender: #2D88FF;
@@ -8418,8 +8418,8 @@ input[type="range"]:hover::-webkit-slider-thumb {
--volume-accent: #1877F2;
--text-primary: #050505;
--text-secondary: #65676B;
--text-muted: #606369;
--text-secondary: #44464A;
--text-muted: #65676B;
--border: #CED0D4;
--border-subtle: #E4E6EB;
@@ -8533,6 +8533,35 @@ input[type="range"]:hover::-webkit-slider-thumb {
border: 1px solid var(--border-subtle);
}
/* Album chip — opaque Facebook blue pill */
[data-theme='the-book'] .badge,
[data-theme='the-book'] .album-detail-badge {
background: #1877F2;
color: #ffffff;
}
/* Back button — opaque Facebook-style pill */
[data-theme='the-book'] .album-detail-back {
background: #E7F3FF;
color: #1877F2;
}
[data-theme='the-book'] .album-detail-back:hover {
background: #D8EAFD;
color: #0d6ae0;
}
/* Queue/Lyrics tab bar — white text on blue sidebar */
[data-theme='the-book'] .queue-tab-btn {
color: rgba(255, 255, 255, 0.65);
}
[data-theme='the-book'] .queue-tab-btn:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.1);
}
[data-theme='the-book'] .queue-tab-btn.active {
color: #ffffff;
}
/* ── ReadIt (Social Media) ──────────────────────────────────── */
[data-theme='readit'] {
color-scheme: dark;
+40 -10
View File
@@ -9,16 +9,36 @@ const MAX_CONCURRENT_FETCHES = 5;
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
const objectUrlCache = new Map<string, string>();
// Concurrency limiter for network fetches
// Concurrency limiter for network fetches.
// Each queue entry is a resolver that signals "slot acquired".
let activeFetches = 0;
const fetchQueue: Array<() => void> = [];
function acquireFetchSlot(): Promise<void> {
/**
* Acquires a fetch slot. Returns true if a slot was granted, false if the
* provided AbortSignal fired while the call was waiting in the queue (in that
* case no slot is held and the caller must NOT call releaseFetchSlot).
*/
function acquireFetchSlot(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) return Promise.resolve(false);
if (activeFetches < MAX_CONCURRENT_FETCHES) {
activeFetches++;
return Promise.resolve();
return Promise.resolve(true);
}
return new Promise(resolve => fetchQueue.push(resolve));
return new Promise<boolean>(resolve => {
const onGrant = () => {
signal?.removeEventListener('abort', onAbort);
resolve(true);
};
const onAbort = () => {
// Remove from queue without consuming a slot — no releaseFetchSlot needed.
const idx = fetchQueue.indexOf(onGrant);
if (idx !== -1) fetchQueue.splice(idx, 1);
resolve(false);
};
fetchQueue.push(onGrant);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function releaseFetchSlot(): void {
@@ -174,9 +194,11 @@ export async function clearImageCache(): Promise<void> {
* Returns a cached object URL for an image.
* @param fetchUrl The actual URL to fetch from (may contain ephemeral auth params).
* @param cacheKey A stable key that identifies the image across sessions.
* @param signal Optional AbortSignal aborts queue-waiting and in-flight fetches
* so navigating away does not leave zombie fetches draining I/O.
*/
export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<string> {
if (!fetchUrl) return '';
export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise<string> {
if (!fetchUrl || signal?.aborted) return '';
// 1. In-memory hit (same session)
const existing = objectUrlCache.get(cacheKey);
@@ -184,6 +206,7 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
// 2. IndexedDB hit (persisted from previous session)
const blob = await getBlob(cacheKey);
if (signal?.aborted) return '';
if (blob) {
const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl);
@@ -191,19 +214,26 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
return objUrl;
}
// 3. Network fetch with concurrency limit → store in IDB → return object URL
await acquireFetchSlot();
// 3. Network fetch with concurrency limit → store in IDB → return object URL.
// acquireFetchSlot returns false (without holding a slot) when aborted in queue.
const acquired = await acquireFetchSlot(signal);
if (!acquired || signal?.aborted) {
if (acquired) releaseFetchSlot();
return '';
}
try {
const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl;
const newBlob = await resp.blob();
if (signal?.aborted) return '';
putBlob(cacheKey, newBlob); // fire-and-forget (includes disk eviction)
const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl);
evictMemoryIfNeeded();
return objUrl;
} catch {
return fetchUrl;
} catch (e) {
// AbortError → return '' (component is gone). Other errors → return raw URL.
return e instanceof DOMException && e.name === 'AbortError' ? '' : fetchUrl;
} finally {
releaseFetchSlot();
}