diff --git a/CHANGELOG.md b/CHANGELOG.md index 102cbfb4..5fe29f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 5a46ca09..3b9df589 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.29.0", + "version": "1.30.0", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d067a0ea..f70be4f2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5c33a80..84190744 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index fa7a2769..b6334935 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1074,6 +1074,7 @@ pub async fn audio_play( duration_hint: f64, replay_gain_db: Option, replay_gain_peak: Option, + 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. diff --git a/src-tauri/src/discord.rs b/src-tauri/src/discord.rs new file mode 100644 index 00000000..399cf078 --- /dev/null +++ b/src-tauri/src/discord.rs @@ -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>); + +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 { + 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, + title: String, + artist: String, + album: Option, + elapsed_secs: Option, +) -> 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) -> 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(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97236eb6..5b091f48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index eef5bf41..adb59c9b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 24f19edc..fc682f36 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 { - // 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 { + 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 { diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 5a15a3d5..45ca4bf6 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -143,8 +143,8 @@ export default function AlbumHeader({ diff --git a/src/components/AppUpdater.tsx b/src/components/AppUpdater.tsx index d107e9a7..15384f7a 100644 --- a/src/components/AppUpdater.tsx +++ b/src/components/AppUpdater.tsx @@ -123,9 +123,15 @@ export default function AppUpdater() { {state.phase === 'available' && (
{canInstall && ( - + <> +

{t('common.updaterExperimentalHint')}

+ + + )} {isLinuxFallback && ( + {albums.length > 0 && (() => { + const progress = id ? bulkProgress[id] : undefined; + const isDone = progress && progress.done === progress.total; + const isDownloading = progress && !isDone; + return ( + + ); + })()}
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 052bcb9e..e58bedad 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -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 ( +
+ +
+ ); + } + return ( +
+ {nameInitial(artist.name)} +
+ ); +} + +function ArtistRowAvatar({ artist, showImages }: { artist: SubsonicArtist; showImages: boolean }) { + const color = nameColor(artist.name); + if (showImages && artist.coverArt) { + return ( +
+ +
+ ); + } + return ( +
+ {nameInitial(artist.name)} +
+ ); } 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() {
+ - ); - })} + {groups[letter].map(artist => ( + + ))}
))} diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index 3299d4d3..3b724899 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -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 (
@@ -67,14 +71,14 @@ export default function Favorites() { )} - {songs.length > 0 && ( + {visibleSongs.length > 0 && (

{t('favorites.songs')}

+
+
+
+

{album.name}

+

{album.artist}

+ {album.year &&

{album.year}

} +
+ + {trackCount} tracks + +
+
+ + ); + }; + + // For artist filter: group by artist name + const renderArtistGroups = () => { + const groups: Record = {}; + 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 => ( +
+

{artistName}

+
+ {groups[artistName].map(renderCard)} +
+
+ )); + }; + + 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 (
@@ -57,60 +147,30 @@ const buildTracks = (albumId: string) => {
- {albums.length === 0 ? ( +
+ {TABS.map(tab => { + const count = countByType(tab.id); + if (tab.id !== 'all' && count === 0) return null; + return ( + + ); + })} +
+ + {filtered.length === 0 ? (
{t('connection.offlineLibraryEmpty')}
+ ) : filter === 'artist' ? ( + renderArtistGroups() ) : (
- {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 ( -
-
- {coverUrl ? ( - - ) : ( -
- -
- )} -
- -
-
-
-

{album.name}

-

{album.artist}

- {album.year &&

{album.year}

} -
- - {trackCount} tracks - -
-
-
- ); - })} + {filtered.map(renderCard)}
)} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 579875c4..63364bf0 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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(null); const [songs, setSongs] = useState([]); @@ -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() { > {t('playlists.addSongs')} + {songs.length > 0 && id && (() => { + const isDownloading = isAlbumDownloading(id); + const isCached = isAlbumDownloaded(id, activeServerId); + const progress = isDownloading ? getAlbumProgress(id) : null; + return ( + + ); + })()} @@ -599,9 +625,9 @@ export default function PlaylistDetail() { diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 565db8bd..32290fb4 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -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(null); const psyDrag = useDragDrop(); const [starredSongs, setStarredSongs] = useState>(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() {
) : (
-
- - {t('randomMix.trackTitle')} - {t('randomMix.trackArtist')} - {t('randomMix.trackAlbum')} - {t('randomMix.trackGenre')} - {t('randomMix.trackDuration')} +
+
+
{t('randomMix.trackTitle')}
+
{t('randomMix.trackArtist')}
+
{t('randomMix.trackAlbum')}
+
{t('randomMix.trackFavorite')}
+
{t('randomMix.trackDuration')}
{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 ( -
playTrack(track, genreMixSongs.map(songToTrack))} role="row" +
{ 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); }} > - -
{song.title}
-
{song.artist}
-
{song.album}
-
{song.genre ?? '—'}
- {formatDuration(song.duration)} -
- ); - })} -
- )} -
- )} +
{ e.stopPropagation(); playTrack(track, queueSongs); }}> + + {isCurrentTrack && isPlaying + ?
+ : } +
+
+
{song.title}
+
+ {artist ? ( + + ) : } +
+
+ {song.album ?? '—'} +
+
+ +
+
{formatDuration(song.duration)}
+
+ ); + })} +
+ )} + + )} {!selectedGenre && (loading && songs.length === 0 ? (
@@ -371,26 +416,37 @@ export default function RandomMix() {
) : (
-
- - {t('randomMix.trackTitle')} - {t('randomMix.trackArtist')} - {t('randomMix.trackAlbum')} - +
+
+
{t('randomMix.trackTitle')}
+
{t('randomMix.trackArtist')}
+
{t('randomMix.trackAlbum')}
+
{t('randomMix.trackGenre')} - - {t('randomMix.trackFavorite')} - {t('randomMix.trackDuration')} +
+
{t('randomMix.trackFavorite')}
+
{t('randomMix.trackDuration')}
{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 (
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); }} > - +
{ e.stopPropagation(); playTrack(track, queueSongs); }}> + + {isCurrentTrack && isPlaying + ?
+ : } +
+
-
- {song.title} -
+
+ {song.title} +
-
- {(() => { - const artist = song.artist; - if (!artist) return ; - const isBlocked = customGenreBlacklist.some(bg => artist.toLowerCase().includes(bg.toLowerCase())); - const justAdded = addedArtist === artist; - return ( +
+ {artist ? ( - ); - })()} -
+ data-tooltip={isArtistBlocked ? t('randomMix.artistBlocked') : isArtistJustAdded ? t('randomMix.artistAddedToBlacklist') : t('randomMix.artistClickHint')} + >{artist} + ) : } +
-
- {song.album} -
+
+ {song.album ?? '—'} +
- {(() => { - const genre = song.genre; - if (!genre) return
; - const isBlocked = AUDIOBOOK_GENRES.some(ag => genre.toLowerCase().includes(ag)) || - customGenreBlacklist.some(bg => genre.toLowerCase().includes(bg.toLowerCase())); - const justAdded = addedGenre === genre; - return ( +
+ {genre ? ( + + ) : } +
+ +
- ); - })()} +
-
- +
{formatDuration(song.duration)}
- - - {formatDuration(song.duration)} - -
- ); - })} -
- ))} + ); + })} +
+ ))} ); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 4fc17d24..38e2feea 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1008,6 +1008,28 @@ export default function Settings() {
+
+
+
{t('settings.showArtistImages')}
+
{t('settings.showArtistImagesDesc')}
+
+ +
+
+
+
+
{t('settings.discordRichPresence')}
+
{t('settings.discordRichPresenceDesc')}
+
+ +
+
{t('settings.nowPlayingEnabled')}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index e08fde0b..4421e4a6 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( 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()( 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 }), diff --git a/src/store/offlineStore.ts b/src/store/offlineStore.ts index a49341b6..68132f35 100644 --- a/src/store/offlineStore.ts +++ b/src/store/offlineStore.ts @@ -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; // key: `${serverId}:${trackId}` albums: Record; // key: `${serverId}:${albumId}` jobs: DownloadJob[]; + /** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */ + bulkProgress: Record; 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; + downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise; + downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise; deleteAlbum: (albumId: string, serverId: string) => Promise; clearAll: (serverId: string) => Promise; getAlbumProgress: (albumId: string) => { done: number; total: number } | null; @@ -74,6 +80,7 @@ export const useOfflineStore = create()( tracks: {}, albums: {}, jobs: [], + bulkProgress: {}, isDownloaded: (trackId, serverId) => !!get().tracks[`${serverId}:${trackId}`], @@ -110,7 +117,7 @@ export const useOfflineStore = create()( 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()( 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()( }, 2500); }, + downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => { + // Deduplicate songs (a track can appear multiple times in a playlist). + const seen = new Set(); + 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; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 3e705648..0f6f24fb 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -73,12 +73,12 @@ interface PlayerState { starredOverrides: Record; 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()( }, // ── 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()( 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()( 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()( 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()( }, // ── 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()( } } } 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()( 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()( 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); diff --git a/src/styles/components.css b/src/styles/components.css index 475d298b..5c3fa0d2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; diff --git a/src/styles/layout.css b/src/styles/layout.css index 461a9a62..f0e4dac3 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; diff --git a/src/styles/theme.css b/src/styles/theme.css index 7d9e11fe..971790e3 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -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; diff --git a/src/utils/imageCache.ts b/src/utils/imageCache.ts index 5589f57d..365b72c4 100644 --- a/src/utils/imageCache.ts +++ b/src/utils/imageCache.ts @@ -9,16 +9,36 @@ const MAX_CONCURRENT_FETCHES = 5; // In-memory map: cacheKey → object URL (insertion-order = LRU approximation) const objectUrlCache = new Map(); -// 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 { +/** + * 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 { + 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(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 { * 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 { - if (!fetchUrl) return ''; +export async function getCachedUrl(fetchUrl: string, cacheKey: string, signal?: AbortSignal): Promise { + 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(); }