diff --git a/CHANGELOG.md b/CHANGELOG.md index f70361c2..74f54e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list in a modal, then play the full queue with no server account — direct stream and cover URLs are resolved anonymously from the share page. * Share playback uses a dedicated scope so an idle server play-queue pull cannot replace the share queue while you are also logged into Navidrome. Share sessions are not restored after an app restart — the server play queue applies as usual. +### Track lists — optional album cover thumbnails + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1280](https://github.com/Psychotoxical/psysonic/pull/1280)** + +* Browse and queue track rows can show the track's **album** cover (per-disc art when the album has distinct disc covers). Covers load through the standard cover cache pipeline — library resolve, viewport ensure, Rust resize to disk tiers — not a separate warm path. +* **Settings → Appearance** adds separate toggles for queue vs browse tracklists. Favorites, playlist, and album-detail track grids gain a flex-resize handle on the title column when covers are shown. + ## Changed diff --git a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs index 74f06f72..305c6980 100644 --- a/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs +++ b/src-tauri/crates/psysonic-core/src/cover_cache_layout.rs @@ -110,6 +110,20 @@ pub fn resolve_album_cover( .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(album); + // Navidrome track-only libraries: keep consensus `mf-*` fetch (library picks the + // first track per album) while the disk slot stays album-scoped. + if !distinct_disc_covers && fetch.starts_with("mf-") && fetch != album { + return Some(CoverEntry { + cache_kind: "album", + cache_entity_id: album.to_string(), + fetch_cover_art_id: fetch.to_string(), + }); + } + let fetch_id = if !distinct_disc_covers && fetch == album { + format!("al-{album}_0") + } else { + fetch.to_string() + }; let cache_entity_id = if distinct_disc_covers && fetch != album { fetch.to_string() } else { @@ -118,7 +132,7 @@ pub fn resolve_album_cover( Some(CoverEntry { cache_kind: "album", cache_entity_id, - fetch_cover_art_id: fetch.to_string(), + fetch_cover_art_id: fetch_id, }) } @@ -271,6 +285,19 @@ mod tests { assert_eq!(e.cache_entity_id, "mf-d2"); } + #[test] + fn resolve_album_keeps_mf_fetch_on_album_bucket() { + let e = resolve_album_cover("al-box", Some("mf-track"), false).unwrap(); + assert_eq!(e.cache_entity_id, "al-box"); + assert_eq!(e.fetch_cover_art_id, "mf-track"); + } + + #[test] + fn resolve_album_navidrome_bare_id() { + let e = resolve_album_cover("2lsdR1ogDKiFcAD6Pcvk4f", None, false).unwrap(); + assert_eq!(e.fetch_cover_art_id, "al-2lsdR1ogDKiFcAD6Pcvk4f_0"); + } + fn test_server_dir(label: &str) -> std::path::PathBuf { let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}")); let _ = std::fs::remove_dir_all(&base); diff --git a/src-tauri/crates/psysonic-library/src/cover_backfill.rs b/src-tauri/crates/psysonic-library/src/cover_backfill.rs index 18893da3..fc2c6947 100644 --- a/src-tauri/crates/psysonic-library/src/cover_backfill.rs +++ b/src-tauri/crates/psysonic-library/src/cover_backfill.rs @@ -606,7 +606,10 @@ mod tests { .unwrap(); assert_eq!(batch.cover_ids, vec!["0DurV2S7arIOBQVEknOPWX".to_string()]); assert_eq!(batch.items[0].cache_kind, "album"); - assert_eq!(batch.items[0].fetch_cover_art_id, "0DurV2S7arIOBQVEknOPWX"); + assert_eq!( + batch.items[0].fetch_cover_art_id, + "al-0DurV2S7arIOBQVEknOPWX_0" + ); } #[test] diff --git a/src-tauri/crates/psysonic-library/src/cover_resolve.rs b/src-tauri/crates/psysonic-library/src/cover_resolve.rs index 16d12014..ddff3294 100644 --- a/src-tauri/crates/psysonic-library/src/cover_resolve.rs +++ b/src-tauri/crates/psysonic-library/src/cover_resolve.rs @@ -107,7 +107,9 @@ pub fn resolve_album_cover_entry( ) .optional() })? { - None => return Ok(None), + None => { + return track_only_album_backfill_entry(store, library_server_id, album_id); + } Some(v) => v, }; // Album rows synced without a cover id (created from a starred/tag/browse path @@ -409,11 +411,9 @@ mod tests { assert_eq!(e.fetch_cover_art_id, "al-ca78bec6_60fc987f"); } - // #1252: an album row synced without a cover id (e.g. via a starred/tag path) - // must fall back to the album's track cover, not the bare album id, so the - // detail header and browse tiles resolve the same fetch id. + // #1252: album row without cover id — use first track mf when present. #[test] - fn resolve_album_falls_back_to_track_cover_when_row_cover_null() { + fn resolve_album_falls_back_to_track_mf_when_row_cover_null() { let store = LibraryStore::open_in_memory(); seed_album(&store, "srv", "al-nocover", None); seed_track(&store, "srv", "tr1", "al-nocover", 1, Some("mf-cover")); @@ -424,6 +424,27 @@ mod tests { assert_eq!(e.fetch_cover_art_id, "mf-cover"); } + #[test] + fn resolve_album_without_album_row_uses_track_only_backfill() { + let store = LibraryStore::open_in_memory(); + seed_track( + &store, + "srv", + "tr1", + "2lsdR1ogDKiFcAD6Pcvk4f", + 1, + Some("mf-fis8alFzjMGlcncxrvmpUV_67afa52a"), + ); + let e = resolve_album_cover_entry(&store, "srv", "2lsdR1ogDKiFcAD6Pcvk4f") + .unwrap() + .unwrap(); + assert_eq!(e.cache_entity_id, "2lsdR1ogDKiFcAD6Pcvk4f"); + assert_eq!( + e.fetch_cover_art_id, + "mf-fis8alFzjMGlcncxrvmpUV_67afa52a" + ); + } + #[test] fn resolve_album_keeps_row_cover_over_track_cover() { let store = LibraryStore::open_in_memory(); diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index ed1a0126..e7a87e04 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -200,6 +200,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Queue toolbar — Navidrome public share: hide Save Playlist, copy original share URL (PR #1279)', 'Windows startup hang after #1274 — boot barrel split, stable Wasapi device IDs, legacy EQ key match (PR #1277)', 'Windows MSI bundle on dev/RC versions — numeric WiX mapping; album easter-egg import chunk (PR #1278)', + 'Track lists — optional album cover thumbnails via standard cover pipeline; queue rows use playback scope (PR #1280)', ], }, { diff --git a/src/cover/TrackRowCoverThumb.tsx b/src/cover/TrackRowCoverThumb.tsx new file mode 100644 index 00000000..67394a62 --- /dev/null +++ b/src/cover/TrackRowCoverThumb.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Music } from 'lucide-react'; +import type { SubsonicSong } from '@/lib/api/subsonicTypes'; +import { useAuthStore } from '@/store/authStore'; +import { readDetailServerId } from '@/lib/navigation/detailServerScope'; +import { CoverArtImage } from '@/cover/CoverArtImage'; +import { + useBrowseListTrackCoverRef, + usePlaybackTrackCoverRef, +} from '@/cover/useLibraryCoverRef'; +import { wakeCoverBackfillForMissingTrack } from '@/cover/wakeCoverBackfillForMissingTrack'; +import { + COVER_ARTIST_TOP_TRACK_CSS_PX, + COVER_TRACK_ROW_CSS_PX, + COVER_TRACK_ROW_MINI_CSS_PX, +} from '@/cover/layoutSizes'; +import { coverServerScopeForServerId } from '@/cover/serverScope'; +import type { CoverArtRef, CoverPrefetchPriority } from '@/cover/types'; +import type { TrackListCoverArtSurface } from '@/cover/useTrackListCoverArtSettings'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; + +export type TrackRowCoverSong = Pick< + SubsonicSong, + 'id' | 'albumId' | 'coverArt' | 'discNumber' +> & { + serverId?: string; + /** Queue placeholder rows use title '…' until resolved. */ + title?: string; +}; + +export type TrackRowCoverThumbSize = 'row' | 'mini' | 'dense'; + +const SIZE_PX: Record = { + row: COVER_TRACK_ROW_CSS_PX, + mini: COVER_TRACK_ROW_MINI_CSS_PX, + dense: COVER_ARTIST_TOP_TRACK_CSS_PX, +}; + +interface BaseProps { + song: TrackRowCoverSong; + size?: TrackRowCoverThumbSize; + className?: string; + /** When omitted, CoverArtImage resolves the nearest scroll ancestor. */ + observeScrollRootId?: string; +} + +interface Props extends BaseProps { + surface: TrackListCoverArtSurface; +} + +function isUnresolvedQueuePlaceholder(song: TrackRowCoverSong): boolean { + return song.title === '…' || !song.id?.trim(); +} + +function TrackRowCoverPlaceholder({ + displayCssPx, + className, + showIcon, +}: { + displayCssPx: number; + className?: string; + showIcon?: boolean; +}) { + return ( + + ); +} + +function TrackRowCoverImage({ + coverRef, + displayCssPx, + className, + observeScrollRootId, + ensurePriority, +}: { + coverRef: CoverArtRef; + displayCssPx: number; + className?: string; + observeScrollRootId?: string; + ensurePriority?: CoverPrefetchPriority; +}) { + return ( + + ); +} + +/** + * Browse/detail track rows — **album** cover (multi-disc → per-disc resolver). + * List thumbs never use per-track `song.coverArt` / `mf-*`; fetch is album-scoped + * from `albumId` (+ library index for `al-*` ids when indexed). + */ +export function BrowseTrackRowCoverThumb({ + song, + size = 'row', + className, + observeScrollRootId, +}: BaseProps) { + const displayCssPx = SIZE_PX[size]; + const albumId = song.albumId?.trim(); + const [searchParams] = useSearchParams(); + const activeServerId = useAuthStore(s => s.activeServerId); + const serverScope = useMemo(() => { + const scopedId = + song.serverId?.trim() + || readDetailServerId(searchParams, activeServerId) + || undefined; + return coverServerScopeForServerId(scopedId); + }, [song.serverId, searchParams, activeServerId]); + // Track library resolve applies `album_has_distinct_disc_covers` from SQLite — + // no album-page visit required for per-disc slots on browse lists. + const coverRef = useBrowseListTrackCoverRef(song, serverScope); + const missingResolvableCover = !albumId; + + useEffect(() => { + if (missingResolvableCover) wakeCoverBackfillForMissingTrack(song); + // song is read only for albumId/coverArt inside the wake helper. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [missingResolvableCover, song.id, song.albumId]); + + if (!albumId || !coverRef) { + return ; + } + + return ( + + ); +} + +/** + * Queue rows — same resolver as playbar / queue hero (`usePlaybackTrackCoverRef`) + * so server switches keep the correct playback scope and album/disc slot. + */ +export function QueueTrackRowCoverThumb({ + song, + size = 'row', + className, + observeScrollRootId, +}: BaseProps) { + const displayCssPx = SIZE_PX[size]; + const albumId = song.albumId?.trim(); + const coverRef = usePlaybackTrackCoverRef(song); + const missingApiCoverArt = !song.coverArt?.trim(); + + useEffect(() => { + if (missingApiCoverArt) wakeCoverBackfillForMissingTrack(song); + // song is read only for albumId/coverArt inside the wake helper. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [missingApiCoverArt, song.id, song.albumId]); + + if (isUnresolvedQueuePlaceholder(song)) { + return ; + } + + if (!albumId || !coverRef) { + return ; + } + + return ( + + ); +} + +/** Routes to browse vs queue resolver based on `surface`. */ +export function TrackRowCoverThumb({ surface, ...props }: Props) { + if (surface === 'queue') return ; + return ; +} + +/** Respects the persisted toggle for `surface` — use in list rows. */ +export function OptionalTrackRowCoverThumb(props: Props) { + const enabled = useTrackListCoverArtEnabled(props.surface); + if (!enabled) return null; + return ; +} + +/** Queue lists — respects queue toggle. */ +export function OptionalQueueTrackRowCoverThumb( + props: Omit, +) { + return ; +} + +/** Browse tracklists — respects pages toggle. */ +export function OptionalBrowseTrackRowCoverThumb( + props: Omit, +) { + return ; +} diff --git a/src/cover/diskSrcLookup.ts b/src/cover/diskSrcLookup.ts index 92e222b2..baceef20 100644 --- a/src/cover/diskSrcLookup.ts +++ b/src/cover/diskSrcLookup.ts @@ -25,6 +25,8 @@ function skipFullResSeedTier(tier: CoverArtTier, fsPath: string): boolean { /** Dense grids: prefer a larger on-disk tier (800) before tiny thumbs when the ideal tier is missing. */ export function gridDiskSrcLookupOrder(want: CoverArtTier): CoverArtTier[] { const out: CoverArtTier[] = [want]; + // Rust peek ladder for tier 64 falls back to 128.webp — mirror that in memory lookup. + if (want === 64 && !out.includes(128)) out.push(128); if (want >= 256 && want < 800) out.push(800); const ladder: CoverArtTier[] = [128, 256, 512, 800]; for (let i = ladder.length - 1; i >= 0; i -= 1) { diff --git a/src/cover/ensureQueue.test.ts b/src/cover/ensureQueue.test.ts index dd395069..fe13aff9 100644 --- a/src/cover/ensureQueue.test.ts +++ b/src/cover/ensureQueue.test.ts @@ -50,8 +50,8 @@ describe('coverEnsureQueued', () => { coverEnsureQueued('s:cover:al-1:128', ref, 128, 'high'), coverEnsureQueued('s:cover:al-1:128', ref, 128, 'low'), ]); - expect(a.path).toBe('/tmp/al-1.webp'); - expect(b.path).toBe('/tmp/al-1.webp'); + expect(a.path).toBe('/tmp/al-al-1_0.webp'); + expect(b.path).toBe('/tmp/al-al-1_0.webp'); expect(ensureImpl).toHaveBeenCalledTimes(1); }); diff --git a/src/cover/layoutSizes.ts b/src/cover/layoutSizes.ts index ce6f074f..87af3d9d 100644 --- a/src/cover/layoutSizes.ts +++ b/src/cover/layoutSizes.ts @@ -1,6 +1,10 @@ import { computeCardGridColumnCount, computeCellWidthPx } from '@/lib/util/cardGridLayout'; export const COVER_DENSE_SEARCH_CSS_PX = 40; +/** Track row / queue list mini album thumb (40×40 CSS px). */ +export const COVER_TRACK_ROW_CSS_PX = 40; +/** Mini player queue row thumb (32×32 CSS px). */ +export const COVER_TRACK_ROW_MINI_CSS_PX = 32; /** Artist detail top-track thumb (32×32 CSS px). */ export const COVER_ARTIST_TOP_TRACK_CSS_PX = 32; export const COVER_DENSE_ARTIST_LIST_CSS_PX = 64; diff --git a/src/cover/resolveEntry.test.ts b/src/cover/resolveEntry.test.ts index 67b274e1..857c2602 100644 --- a/src/cover/resolveEntry.test.ts +++ b/src/cover/resolveEntry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { albumHasDistinctDiscCovers, + normalizeAlbumLibraryEntry, resolveAlbumCoverEntry, resolveArtistCoverEntry, resolveSongFetchCoverArtId, @@ -14,9 +15,17 @@ describe('resolveAlbumCoverEntry', () => { expect(e?.fetchCoverArtId).toBe('al-0Dur_abc'); }); - it('ignores mf fetch unless distinctDiscCovers', () => { + it('keeps mf fetch on album bucket unless distinctDiscCovers', () => { expect(resolveAlbumCoverEntry('al-box', 'mf-d2')?.cacheEntityId).toBe('al-box'); + expect(resolveAlbumCoverEntry('al-box', 'mf-d2')?.fetchCoverArtId).toBe('mf-d2'); expect(resolveAlbumCoverEntry('al-box', 'mf-d2', true)?.cacheEntityId).toBe('mf-d2'); + expect(resolveAlbumCoverEntry('al-box', 'mf-d2', true)?.fetchCoverArtId).toBe('mf-d2'); + }); + + it('uses Navidrome al-_0 fetch for bare album ids', () => { + expect(resolveAlbumCoverEntry('2lsdR1ogDKiFcAD6Pcvk4f', null)?.fetchCoverArtId).toBe( + 'al-2lsdR1ogDKiFcAD6Pcvk4f_0', + ); }); }); @@ -80,3 +89,24 @@ describe('albumHasDistinctDiscCovers', () => { ).toBe(false); }); }); + +describe('normalizeAlbumLibraryEntry', () => { + it('keeps consensus mf-* fetch on the album bucket', () => { + const e = normalizeAlbumLibraryEntry('al-1', { + cacheKind: 'album', + cacheEntityId: 'al-1', + fetchCoverArtId: 'mf-track', + }); + expect(e.fetchCoverArtId).toBe('mf-track'); + }); + + it('keeps per-disc mf-* when cache entity is the disc bucket', () => { + const e = normalizeAlbumLibraryEntry('al-box', { + cacheKind: 'album', + cacheEntityId: 'mf-d2', + fetchCoverArtId: 'mf-d2', + }); + expect(e.cacheEntityId).toBe('mf-d2'); + expect(e.fetchCoverArtId).toBe('mf-d2'); + }); +}); diff --git a/src/cover/resolveEntry.ts b/src/cover/resolveEntry.ts index 08ad7a97..30519615 100644 --- a/src/cover/resolveEntry.ts +++ b/src/cover/resolveEntry.ts @@ -68,6 +68,13 @@ export function albumHasDistinctDiscCovers( return discCovers.size > 1; } +/** Re-apply album fetch rules to a library-resolved entry (SQLite may still carry per-track `mf-*`). */ +export function normalizeAlbumLibraryEntry(albumId: string, entry: CoverEntry): CoverEntry { + const album = albumId.trim(); + const distinctDiscCovers = entry.cacheEntityId.trim() !== album; + return resolveAlbumCoverEntry(album, entry.fetchCoverArtId, distinctDiscCovers)!; +} + /** Album entity — one cache slot per album unless `distinctDiscCovers`. */ export function resolveAlbumCoverEntry( albumId: string, @@ -76,7 +83,18 @@ export function resolveAlbumCoverEntry( ): CoverEntry | undefined { const album = albumId.trim(); if (!album) return undefined; - const fetch = (coverArtId?.trim() || album); + let fetch = coverArtId?.trim() || album; + // Navidrome track-only libraries (no `album` row): each track carries its own + // `mf-*` id but getCoverArt still serves album artwork. Keep one consensus mf + // fetch per album (library backfill picks the first track) while the disk slot + // stays album-scoped — never one cache dir per track in browse lists. + if (!distinctDiscCovers && fetch.startsWith('mf-') && fetch !== album) { + return { cacheKind: 'album', cacheEntityId: album, fetchCoverArtId: fetch }; + } + // Bare album ids need `al-_0` on Navidrome when no mf id is available. + if (!distinctDiscCovers && fetch === album) { + fetch = `al-${album}_0`; + } const cacheEntityId = distinctDiscCovers && fetch !== album ? fetch : album; return { cacheKind: 'album', cacheEntityId, fetchCoverArtId: fetch }; diff --git a/src/cover/resolveEntryLibrary.ts b/src/cover/resolveEntryLibrary.ts index 6eb7d96d..9c9d31c2 100644 --- a/src/cover/resolveEntryLibrary.ts +++ b/src/cover/resolveEntryLibrary.ts @@ -9,6 +9,7 @@ import { useAuthStore } from '../store/authStore'; import { COVER_SCOPE_ACTIVE, type CoverArtRef, CoverCacheKind, CoverServerScope } from './types'; import { coverEntryToRef, + normalizeAlbumLibraryEntry, resolveAlbumCoverEntry, resolveArtistCoverEntry, resolveTrackCoverEntry, @@ -126,9 +127,11 @@ export async function resolveAlbumCoverRefFromLibrary( fallbackCoverArt: string | null | undefined, serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, ): Promise { - const entry = - (await libraryResolveCoverEntry(libraryServerIdFromScope(serverScope), 'album', albumId)) - ?? resolveAlbumCoverEntry(albumId, fallbackCoverArt); + const raw = + await libraryResolveCoverEntry(libraryServerIdFromScope(serverScope), 'album', albumId); + const entry = raw + ? normalizeAlbumLibraryEntry(albumId, raw) + : resolveAlbumCoverEntry(albumId, fallbackCoverArt); return coverEntryToRef(entry!, serverScope); } @@ -151,12 +154,21 @@ function pickTrackCoverEntry( const albumId = song.albumId?.trim(); const fromClient = resolveTrackCoverEntry(song, distinctDiscCovers); if (!fromLibrary) return fromClient; - if (!fromClient) return fromLibrary; + if (!fromClient) { + return albumId && fromLibrary.cacheKind === 'album' + ? normalizeAlbumLibraryEntry(albumId, fromLibrary) + : fromLibrary; + } + + const normalizedLibrary = + albumId && fromLibrary.cacheKind === 'album' + ? normalizeAlbumLibraryEntry(albumId, fromLibrary) + : fromLibrary; const songArt = resolveSongFetchCoverArtId(song); const libraryIsAlbumBucket = Boolean(albumId) - && fromLibrary.cacheEntityId === albumId + && normalizedLibrary.cacheEntityId === albumId && fromClient.cacheEntityId !== albumId; if ( @@ -168,11 +180,11 @@ function pickTrackCoverEntry( return fromClient; } - if (fromClient.cacheEntityId !== fromLibrary.cacheEntityId && distinctDiscCovers) { + if (fromClient.cacheEntityId !== normalizedLibrary.cacheEntityId && distinctDiscCovers) { return fromClient; } - return fromLibrary; + return normalizedLibrary; } export async function resolveTrackCoverRefFromLibrary( diff --git a/src/cover/tiers.test.ts b/src/cover/tiers.test.ts index 1545a43a..cca506af 100644 --- a/src/cover/tiers.test.ts +++ b/src/cover/tiers.test.ts @@ -19,4 +19,8 @@ describe('resolveCoverDisplayTier', () => { expect(resolveCoverDisplayTier(40, { dpr: 2, surface: 'dense' })).toBe(128); expect(resolveCoverDisplayTier(64, { dpr: 2, surface: 'dense' })).toBe(128); }); + + it('floors dense 32px thumbs at 128 (Rust derive minimum)', () => { + expect(resolveCoverDisplayTier(32, { dpr: 2, surface: 'dense' })).toBe(128); + }); }); diff --git a/src/cover/tiers.ts b/src/cover/tiers.ts index e6edf4b8..f45e0487 100644 --- a/src/cover/tiers.ts +++ b/src/cover/tiers.ts @@ -2,6 +2,9 @@ import { COVER_ART_DENSE_MAX_TIER, COVER_ART_TIERS, type CoverArtTier, type Cove export { COVER_ART_TIERS, COVER_ART_DENSE_MAX_TIER }; +/** Smallest WebP tier Rust writes (`cover_cache::DERIVE_TIERS`). */ +export const COVER_ART_MIN_DERIVED_TIER = 128 as const; + export function resolveCoverDisplayTier( displayCssPx: number, opts?: { dpr?: number; fullRes?: boolean; surface?: CoverSurfaceKind }, @@ -10,6 +13,7 @@ export function resolveCoverDisplayTier( const dpr = opts?.dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1); const neededPx = Math.ceil(displayCssPx * dpr); let tier = COVER_ART_TIERS.find(t => t !== 2000 && t >= neededPx) ?? 800; + if (tier < COVER_ART_MIN_DERIVED_TIER) tier = COVER_ART_MIN_DERIVED_TIER; if (opts?.surface === 'dense' && tier > COVER_ART_DENSE_MAX_TIER) { tier = COVER_ART_DENSE_MAX_TIER; } diff --git a/src/cover/useLibraryCoverRef.ts b/src/cover/useLibraryCoverRef.ts index 08b8cf94..fcb0c353 100644 --- a/src/cover/useLibraryCoverRef.ts +++ b/src/cover/useLibraryCoverRef.ts @@ -65,7 +65,9 @@ export function useAlbumCoverRef( const id = albumId?.trim(); if (!id) return null; return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers }); - }, [albumId, fallbackCoverArt, serverScope, distinctDiscCovers]); + // `serverScope` is keyed via stable `scopeKey` — see effect deps below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [albumId, fallbackCoverArt, scopeKey, distinctDiscCovers]); const [ref, setRef] = useState(syncRef); @@ -92,6 +94,79 @@ export function useAlbumCoverRef( return libraryResolve ? ref : syncRef; } +/** + * Browse track-list rows — **library fetch id first**, then mount CoverArtImage. + * SQLite often stores per-track `mf-*` in `cover_art_id`; the sync album ref would + * race ensure with the wrong id before `library_resolve_cover_entry` returns the + * album row's `al-*` (or album id fallback). + */ +export function useBrowseListAlbumCoverRef( + albumId: string | null | undefined, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): CoverArtRef | null { + const scopeKey = coverScopeKey(serverScope); + const id = albumId?.trim() ?? ''; + const [ref, setRef] = useState(null); + + useEffect(() => { + if (!id) { + // React Compiler set-state-in-effect rule: clear stale ref when album id disappears. + // eslint-disable-next-line react-hooks/set-state-in-effect + setRef(null); + return; + } + let cancelled = false; + setRef(null); + void resolveAlbumCoverRefFromLibrary(id, id, serverScope).then(next => { + if (!cancelled) setRef(next); + }); + return () => { + cancelled = true; + }; + // serverScope keyed via scopeKey — see useAlbumCoverRef. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, scopeKey]); + + return ref; +} + +/** Multi-disc browse rows — library-resolved per-disc slot before ensure. */ +export function useBrowseListTrackCoverRef( + song: Pick | null | undefined, + serverScope: CoverServerScope = COVER_SCOPE_ACTIVE, +): CoverArtRef | undefined { + const scopeKey = coverScopeKey(serverScope); + const songId = song?.id?.trim() ?? ''; + const albumId = song?.albumId?.trim() ?? ''; + const coverArt = song?.coverArt; + const discNumber = song?.discNumber; + const [ref, setRef] = useState(undefined); + + useEffect(() => { + if (!songId || !albumId || !song) { + // React Compiler set-state-in-effect rule: clear stale ref when track identity clears. + // eslint-disable-next-line react-hooks/set-state-in-effect + setRef(undefined); + return; + } + let cancelled = false; + setRef(undefined); + void resolveTrackCoverRefFromLibrary( + { id: songId, albumId, coverArt, discNumber }, + serverScope, + ).then(next => { + if (!cancelled) setRef(next ?? undefined); + }); + return () => { + cancelled = true; + }; + // serverScope keyed via scopeKey — see useTrackCoverRef. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [songId, albumId, coverArt, discNumber, scopeKey]); + + return ref; +} + /** Artist grid — sync fallback, then library index. */ export function useArtistCoverRef( artistId: string | null | undefined, @@ -105,7 +180,9 @@ export function useArtistCoverRef( const id = artistId?.trim(); if (!id) return null; return artistCoverRef(id, fallbackCoverArt, serverScope); - }, [artistId, fallbackCoverArt, serverScope]); + // `serverScope` is keyed via stable `scopeKey` — see effect deps below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artistId, fallbackCoverArt, scopeKey]); const [ref, setRef] = useState(syncRef); diff --git a/src/cover/useTrackListCoverArtSettings.ts b/src/cover/useTrackListCoverArtSettings.ts new file mode 100644 index 00000000..00191aa4 --- /dev/null +++ b/src/cover/useTrackListCoverArtSettings.ts @@ -0,0 +1,11 @@ +import { useAuthStore } from '@/store/authStore'; +import { useThemeStore } from '@/store/themeStore'; + +export type TrackListCoverArtSurface = 'queue' | 'pages'; + +/** Separate persisted toggles: queue chrome vs browse tracklists. */ +export function useTrackListCoverArtEnabled(surface: TrackListCoverArtSurface): boolean { + const queueEnabled = useAuthStore(s => s.queueTrackListCovers); + const pagesEnabled = useThemeStore(s => s.trackListCoverArtOnPages); + return surface === 'queue' ? queueEnabled : pagesEnabled; +} diff --git a/src/cover/useWarmTrackListAlbumCovers.test.ts b/src/cover/useWarmTrackListAlbumCovers.test.ts new file mode 100644 index 00000000..8710b1e2 --- /dev/null +++ b/src/cover/useWarmTrackListAlbumCovers.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { uniqueAlbumIdsFromSongs } from '@/cover/warmDiskPeek'; + +describe('uniqueAlbumIdsFromSongs', () => { + it('dedupes by albumId and respects limit', () => { + const ids = uniqueAlbumIdsFromSongs( + [ + { albumId: 'a1' }, + { albumId: 'a1' }, + { albumId: 'a2' }, + { albumId: 'a3' }, + ], + 2, + ); + expect(ids).toEqual(['a1', 'a2']); + }); + + it('skips empty album ids', () => { + expect(uniqueAlbumIdsFromSongs([{ albumId: '' }, { albumId: ' ' }, { albumId: 'x' }])).toEqual(['x']); + }); +}); diff --git a/src/cover/useWarmTrackListAlbumCovers.ts b/src/cover/useWarmTrackListAlbumCovers.ts new file mode 100644 index 00000000..7b6b151a --- /dev/null +++ b/src/cover/useWarmTrackListAlbumCovers.ts @@ -0,0 +1,57 @@ +import { useEffect, useMemo } from 'react'; +import type { SubsonicSong } from '@/lib/api/subsonicTypes'; +import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes'; +import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch'; +import { + uniqueAlbumIdsFromSongs, + warmUniqueAlbumCoversFromLibrary, +} from '@/cover/warmDiskPeek'; + +const DEFAULT_LIMIT = 48; + +type SongAlbumSource = Pick; + +/** + * Standard cover pipeline warm for track-list surfaces: dedupe visible songs to + * album ids, register library prefetch, peek disk tiers, and high-priority ensure + * misses — same building blocks as album grids, without per-track mf-* fetch ids. + */ +export function useWarmTrackListAlbumCovers( + songs: ReadonlyArray, + displayCssPx: number = COVER_ARTIST_TOP_TRACK_CSS_PX, + opts?: { enabled?: boolean; limit?: number }, +): void { + const enabled = opts?.enabled ?? true; + const limit = opts?.limit ?? DEFAULT_LIMIT; + + const albumIds = useMemo( + () => uniqueAlbumIdsFromSongs(songs, limit), + [songs, limit], + ); + const warmKey = useMemo(() => albumIds.join('\u0001'), [albumIds]); + const prefetchAlbums = useMemo( + () => albumIds.map(id => ({ id })), + [albumIds], + ); + + useLibraryCoverPrefetch( + prefetchAlbums.length > 0 + ? [{ albums: prefetchAlbums, limit, priority: 'high' }] + : [], + [warmKey, enabled], + ); + + useEffect(() => { + if (!enabled || displayCssPx <= 0 || albumIds.length === 0) return; + let cancelled = false; + void warmUniqueAlbumCoversFromLibrary(albumIds, displayCssPx, 'dense').then(() => { + if (cancelled) return; + }); + return () => { + cancelled = true; + }; + // albumIds content is keyed by `warmKey`; listing the array retriggers warm on + // benign parent re-renders that rebuild the songs slice reference. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, warmKey, displayCssPx]); +} diff --git a/src/cover/wakeCoverBackfillForMissingTrack.test.ts b/src/cover/wakeCoverBackfillForMissingTrack.test.ts new file mode 100644 index 00000000..cb07f642 --- /dev/null +++ b/src/cover/wakeCoverBackfillForMissingTrack.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { wakeCoverBackfillForMissingTrack } from '@/cover/wakeCoverBackfillForMissingTrack'; +import { wakeLibraryCoverBackfill } from '@/lib/library/coverBackfillWake'; +import { useAuthStore } from '@/store/authStore'; +import { useCoverStrategyStore } from '@/store/coverStrategyStore'; + +vi.mock('@/lib/library/coverBackfillWake', () => ({ + wakeLibraryCoverBackfill: vi.fn(), +})); + +describe('wakeCoverBackfillForMissingTrack', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + vi.mocked(wakeLibraryCoverBackfill).mockClear(); + useAuthStore.setState({ + activeServerId: 'srv-1', + servers: [{ + id: 'srv-1', + name: 'Test', + url: 'http://music.example', + username: 'u', + password: 'p', + }], + }); + useCoverStrategyStore.setState({ strategy: 'aggressive', strategyByServer: {} }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('wakes backfill when albumId is missing', () => { + wakeCoverBackfillForMissingTrack({ albumId: '', coverArt: 'cv1' }); + expect(wakeLibraryCoverBackfill).toHaveBeenCalledTimes(1); + }); + + it('does not wake when albumId alone resolves a fetch id', () => { + vi.setSystemTime(10_000); + wakeCoverBackfillForMissingTrack({ albumId: 'al-1', coverArt: '' }); + expect(wakeLibraryCoverBackfill).not.toHaveBeenCalled(); + }); + + it('does not wake for per-track mf-* when albumId resolves fetch', () => { + vi.setSystemTime(15_000); + wakeCoverBackfillForMissingTrack({ albumId: 'al-1', coverArt: 'mf-track123' }); + expect(wakeLibraryCoverBackfill).not.toHaveBeenCalled(); + }); + + it('does not wake when both albumId and coverArt are present', () => { + vi.setSystemTime(20_000); + wakeCoverBackfillForMissingTrack({ albumId: 'al-1', coverArt: 'cv1' }); + expect(wakeLibraryCoverBackfill).not.toHaveBeenCalled(); + }); + + it('does not wake under lazy cover strategy', () => { + vi.setSystemTime(30_000); + useCoverStrategyStore.setState({ strategy: 'lazy', strategyByServer: {} }); + wakeCoverBackfillForMissingTrack({ albumId: '', coverArt: '' }); + expect(wakeLibraryCoverBackfill).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cover/wakeCoverBackfillForMissingTrack.ts b/src/cover/wakeCoverBackfillForMissingTrack.ts new file mode 100644 index 00000000..a2610e53 --- /dev/null +++ b/src/cover/wakeCoverBackfillForMissingTrack.ts @@ -0,0 +1,31 @@ +import { wakeLibraryCoverBackfill } from '@/lib/library/coverBackfillWake'; +import { coverStrategyAllowsLibraryBackfill } from '@/lib/library/coverStrategy'; +import { resolveAlbumCoverEntry } from '@/cover/resolveEntry'; +import { useAuthStore } from '@/store/authStore'; +import { useCoverStrategyStore } from '@/store/coverStrategyStore'; + +let lastWakeMs = 0; +const WAKE_COOLDOWN_MS = 4_000; + +/** + * When a visible track row lacks index metadata needed for a cover ref, nudge + * the native library cover backfill (aggressive strategy only). Throttled so + * virtualized lists do not spam wakes. + */ +export function wakeCoverBackfillForMissingTrack( + song: { albumId?: string | null; coverArt?: string | null; serverId?: string | null }, +): void { + const albumId = song.albumId?.trim(); + if (albumId && resolveAlbumCoverEntry(albumId, song.coverArt)?.fetchCoverArtId) return; + + const now = Date.now(); + if (lastWakeMs > 0 && now - lastWakeMs < WAKE_COOLDOWN_MS) return; + + const serverId = song.serverId?.trim() || useAuthStore.getState().activeServerId; + if (!serverId) return; + const strategy = useCoverStrategyStore.getState().getStrategyForServer(serverId); + if (!coverStrategyAllowsLibraryBackfill(strategy)) return; + + lastWakeMs = now; + wakeLibraryCoverBackfill(); +} diff --git a/src/cover/warmDiskPeek.ts b/src/cover/warmDiskPeek.ts index 691f75ed..76b0e3b4 100644 --- a/src/cover/warmDiskPeek.ts +++ b/src/cover/warmDiskPeek.ts @@ -76,12 +76,64 @@ export async function collectSongCoverWarmItems( for (const s of songs) { if (!s.albumId || out.length >= limit) break; out.push( - await coverWarmItemFromLibrary(s.albumId, s.coverArt ?? s.albumId, displayCssPx, surface), + await coverWarmItemFromLibrary(s.albumId, s.albumId, displayCssPx, surface), ); } return out; } +/** Dedupe track rows to album ids for list warm/prefetch (one cover per album). */ +export function uniqueAlbumIdsFromSongs( + songs: ReadonlyArray<{ albumId?: string | null }>, + limit = 48, +): string[] { + const seen = new Set(); + const out: string[] = []; + for (const song of songs) { + const id = song.albumId?.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(id); + if (out.length >= limit) break; + } + return out; +} + +/** + * Library-resolved peek + high-priority ensure for deduped album covers referenced + * by a track list window (browse rows, playlist suggestions, virtual slice). + */ +export async function warmUniqueAlbumCoversFromLibrary( + albumIds: readonly string[], + displayCssPx: number, + surface: CoverSurfaceKind = 'dense', +): Promise { + if (albumIds.length === 0 || displayCssPx <= 0) return; + const items = await Promise.all( + albumIds.map(albumId => coverWarmItemFromLibrary(albumId, albumId, displayCssPx, surface)), + ); + const batch = dedupeWarmItems(items); + if (batch.length === 0) return; + + await warmCoverDiskSrcBatch(batch); + const tier = resolveCoverDisplayTier(displayCssPx, { surface }); + const needEnsure = batch.filter(item => !getDiskSrcForGrid(item.ref, tier)); + if (needEnsure.length === 0) return; + + const PRIME_CHUNK = 8; + for (let i = 0; i < needEnsure.length; i += PRIME_CHUNK) { + const chunk = needEnsure.slice(i, i + PRIME_CHUNK); + await Promise.all( + chunk.map(async item => { + const result = await coverEnsureQueued(item.storageKey, item.ref, tier, 'high'); + if (result.hit && result.path) { + rememberGridDiskSrc(item.ref, tier, result.path); + } + }), + ); + } +} + /** * One IPC round-trip: seed `diskSrcCache` from existing `.webp` before cells hit the ensure queue. */ diff --git a/src/features/album/components/AlbumTrackList.tsx b/src/features/album/components/AlbumTrackList.tsx index a2c0b517..ce056eb7 100644 --- a/src/features/album/components/AlbumTrackList.tsx +++ b/src/features/album/components/AlbumTrackList.tsx @@ -72,7 +72,7 @@ export default function AlbumTrackList({ const { colVisible, visibleCols, gridStyle, - startResize, toggleColumn, resetColumns, + startResize, startFlexColumnResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns'); @@ -158,6 +158,7 @@ export default function AlbumTrackList({ inSelectMode={inSelectMode} toggleAll={toggleAll} startResize={startResize} + startFlexColumnResize={startFlexColumnResize} t={t} /> diff --git a/src/features/album/components/AlbumTrackListMobile.tsx b/src/features/album/components/AlbumTrackListMobile.tsx index db5014b4..d6417a3e 100644 --- a/src/features/album/components/AlbumTrackListMobile.tsx +++ b/src/features/album/components/AlbumTrackListMobile.tsx @@ -4,6 +4,8 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import type { Track } from '@/lib/media/trackTypes'; import { songToTrack } from '@/lib/media/songToTrack'; import { formatLongDuration } from '@/lib/format/formatDuration'; +import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; interface Props { discNums: number[]; @@ -41,6 +43,8 @@ export function AlbumTrackListMobile({ onPlaySong, onContextMenu, }: Props) { + const showCovers = useTrackListCoverArtEnabled('pages'); + return (
{discNums.map(discNum => ( @@ -58,7 +62,7 @@ export function AlbumTrackListMobile({ return (
onPlaySong(song)} onContextMenu={e => { e.preventDefault(); @@ -74,6 +78,7 @@ export function AlbumTrackListMobile({ ) : ( {song.track ?? ''} )} + {song.title}
{formatLongDuration(song.duration)} diff --git a/src/features/album/components/TrackRow.tsx b/src/features/album/components/TrackRow.tsx index c111d36e..d6ccb448 100644 --- a/src/features/album/components/TrackRow.tsx +++ b/src/features/album/components/TrackRow.tsx @@ -16,6 +16,8 @@ import { formatLastSeen } from '@/lib/format/userMgmtHelpers'; import i18n from '@/lib/i18n'; import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline'; import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs'; +import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; type ContextMenuFn = ( x: number, @@ -76,6 +78,7 @@ export const TrackRow = React.memo(function TrackRow({ const { t } = useTranslation(); const navigate = useNavigate(); const showBitrate = useThemeStore(s => s.showBitrate); + const showCovers = useTrackListCoverArtEnabled('pages'); const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); const isActive = currentTrackId === song.id; const isPreviewing = usePreviewStore(s => s.previewingId === song.id); @@ -106,6 +109,9 @@ export const TrackRow = React.memo(function TrackRow({ case 'title': return (
+ {showCovers ? ( + + ) : null} + {song.title}
); diff --git a/src/features/favorites/components/FavoritesSongsTracklist.tsx b/src/features/favorites/components/FavoritesSongsTracklist.tsx index 5ea0d17f..628822a2 100644 --- a/src/features/favorites/components/FavoritesSongsTracklist.tsx +++ b/src/features/favorites/components/FavoritesSongsTracklist.tsx @@ -17,6 +17,9 @@ import { appendServerQuery } from '@/lib/navigation/detailServerScope'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll'; import { useElementClientHeightById } from '@/lib/hooks/useResizeClientHeight'; import { SORTABLE_COLUMNS } from '@/features/favorites/hooks/useFavoritesSongFiltering'; +import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes'; +import { useWarmTrackListAlbumCovers } from '@/cover/useWarmTrackListAlbumCovers'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; interface Props { visibleSongs: SubsonicSong[]; @@ -35,6 +38,7 @@ interface Props { pickerRef: React.RefObject; tracklistRef: React.RefObject; startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; + startFlexColumnResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; handleSortClick: (key: string) => void; getSortIndicator: (key: string) => React.ReactNode; ratings: Record; @@ -47,7 +51,7 @@ export default function FavoritesSongsTracklist({ visibleSongs, selectedIds, selectedCount, inSelectMode, toggleSelect, allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, - startResize, handleSortClick, getSortIndicator, + startResize, startFlexColumnResize, handleSortClick, getSortIndicator, ratings, handleRate, removeSong, hasFilters, }: Props) { const { t } = useTranslation(); @@ -60,6 +64,7 @@ export default function FavoritesSongsTracklist({ const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const showBitrate = useThemeStore(s => s.showBitrate); + const trackListCoversOn = useTrackListCoverArtEnabled('pages'); const psyDrag = useDragDrop(); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); @@ -177,6 +182,15 @@ export default function FavoritesSongsTracklist({ const virtualItems = rowVirtualizer.getVirtualItems(); + const warmVisibleSongs = useMemo( + () => virtualItems.map(vi => visibleSongs[vi.index]), + [virtualItems, visibleSongs], + ); + + useWarmTrackListAlbumCovers(warmVisibleSongs, COVER_ARTIST_TOP_TRACK_CSS_PX, { + enabled: trackListCoversOn, + }); + return ( <> 0; + const titleColIndex = visibleCols.findIndex(c => c.key === 'title'); + const titleCol = titleColIndex >= 0 ? visibleCols[titleColIndex] : undefined; return ( -
+
# + {titleCol?.flex && ( +
startFlexColumnResize(e, titleColIndex, 1)} + /> + )}
); } @@ -240,7 +262,12 @@ export default function FavoritesSongsTracklist({ {label} {canSort && getSortIndicator('title')}
- {hasNextCol &&
startResize(e, colIndex + 1, -1)} />} + {hasNextCol && ( +
startFlexColumnResize(e, colIndex, 1)} + /> + )}
); } diff --git a/src/features/favorites/pages/Favorites.tsx b/src/features/favorites/pages/Favorites.tsx index 55bb813e..00739590 100644 --- a/src/features/favorites/pages/Favorites.tsx +++ b/src/features/favorites/pages/Favorites.tsx @@ -1,6 +1,6 @@ import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync'; import React, { useEffect, useMemo, useState } from 'react'; -import { useTracklistColumns, type ColDef } from '@/lib/hooks/useTracklistColumns'; +import { useTracklistColumns, type ColDef, TRACK_TITLE_FLEX_COL } from '@/lib/hooks/useTracklistColumns'; import { TopFavoriteArtistsRow } from '@/features/favorites/components/TopFavoriteArtists'; import { RadioStationRow } from '@/features/favorites/components/RadioFavorites'; import FavoritesSongsSectionHeader from '@/features/favorites/components/FavoritesSongsSectionHeader'; @@ -18,7 +18,7 @@ import FavoritesOfflineHeader from '@/features/favorites/components/FavoritesOff const FAV_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, - { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, + { key: 'title', i18nKey: 'trackTitle', ...TRACK_TITLE_FLEX_COL, required: true }, { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false }, { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false }, @@ -59,7 +59,7 @@ export default function Favorites() { // ── Column resize/visibility (must be before early return) ─────────────── const { colVisible, visibleCols, gridStyle, - startResize, toggleColumn, resetColumns, + startResize, startFlexColumnResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns'); @@ -208,6 +208,7 @@ export default function Favorites() { pickerRef={pickerRef} tracklistRef={tracklistRef} startResize={startResize} + startFlexColumnResize={startFlexColumnResize} handleSortClick={handleSortClick} getSortIndicator={getSortIndicator} ratings={ratings} diff --git a/src/features/fullscreenPlayer/components/FsQueueModal.tsx b/src/features/fullscreenPlayer/components/FsQueueModal.tsx index d948ff96..46dc637b 100644 --- a/src/features/fullscreenPlayer/components/FsQueueModal.tsx +++ b/src/features/fullscreenPlayer/components/FsQueueModal.tsx @@ -10,6 +10,8 @@ import { resolveBatch, } from '@/features/playback/store/queueTrackResolver'; import { formatTrackTime } from '@/lib/format/formatDuration'; +import { OptionalQueueTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; interface Props { onClose: () => void; @@ -25,6 +27,7 @@ export const FsQueueModal = memo(function FsQueueModal({ onClose }: Props) { const queueItems = usePlayerStore(s => s.queueItems); const queueIndex = usePlayerStore(s => s.queueIndex); const playTrack = usePlayerStore(s => s.playTrack); + const showCovers = useTrackListCoverArtEnabled('queue'); // Re-resolve as the resolver cache fills. const version = useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); @@ -73,13 +76,27 @@ export const FsQueueModal = memo(function FsQueueModal({ onClose }: Props) { upcoming.map(({ track, absIdx }) => ( + {song.title}
); diff --git a/src/features/playlist/components/PlaylistSuggestions.tsx b/src/features/playlist/components/PlaylistSuggestions.tsx index 092b5d89..42d6e914 100644 --- a/src/features/playlist/components/PlaylistSuggestions.tsx +++ b/src/features/playlist/components/PlaylistSuggestions.tsx @@ -16,6 +16,10 @@ import { codecLabel } from '@/lib/format/playlistDetailHelpers'; import { formatLastSeen } from '@/lib/format/userMgmtHelpers'; import { formatTrackTime } from '@/lib/format/formatDuration'; import i18n from '@/lib/i18n'; +import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; +import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes'; +import { useWarmTrackListAlbumCovers } from '@/cover/useWarmTrackListAlbumCovers'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']); @@ -56,12 +60,20 @@ export default function PlaylistSuggestions({ const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const showBitrate = useThemeStore(s => s.showBitrate); + const trackListCoversOn = useTrackListCoverArtEnabled('pages'); const suggestionsVisible = usePlaylistLayoutStore(s => s.items.find(i => i.id === 'suggestions')?.visible !== false); - if (!suggestionsVisible) return null; + const filteredSuggestions = React.useMemo( + () => suggestions.filter(s => !existingIds.has(s.id)), + [suggestions, existingIds], + ); - const filteredSuggestions = suggestions.filter(s => !existingIds.has(s.id)); + useWarmTrackListAlbumCovers(filteredSuggestions, COVER_ARTIST_TOP_TRACK_CSS_PX, { + enabled: trackListCoversOn && suggestionsVisible && filteredSuggestions.length > 0, + }); + + if (!suggestionsVisible) return null; return (
@@ -170,6 +182,7 @@ export default function PlaylistSuggestions({ ? : } + {song.title}
); diff --git a/src/features/playlist/components/PlaylistTracklist.tsx b/src/features/playlist/components/PlaylistTracklist.tsx index e523927e..4500a79f 100644 --- a/src/features/playlist/components/PlaylistTracklist.tsx +++ b/src/features/playlist/components/PlaylistTracklist.tsx @@ -20,6 +20,9 @@ import { useOrbitSongRowBehavior } from '@/features/orbit'; import { songToTrack } from '@/lib/media/songToTrack'; import type { PlaylistSortKey, PlaylistSortDir } from '@/features/playlist/utils/playlistDisplayedSongs'; import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu'; +import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes'; +import { useWarmTrackListAlbumCovers } from '@/cover/useWarmTrackListAlbumCovers'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; const PL_CENTERED = new Set(['favorite', 'rating', 'duration', 'playCount', 'bpm']); @@ -35,6 +38,7 @@ interface Props { setPickerOpen: React.Dispatch>; pickerRef: React.RefObject; startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; + startFlexColumnResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void; tracklistRef: React.RefObject; // Data @@ -86,7 +90,7 @@ interface Props { export default function PlaylistTracklist({ allColumns, visibleCols, gridStyle, colVisible, toggleColumn, resetColumns, - pickerOpen, setPickerOpen, pickerRef, startResize, tracklistRef, + pickerOpen, setPickerOpen, pickerRef, startResize, startFlexColumnResize, tracklistRef, songs, displayedSongs, displayedTracks, isFiltered, hasActiveFilter, id, sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount, selectedIds, setSelectedIds, allSelected, toggleAll, toggleSelect, @@ -107,6 +111,7 @@ export default function PlaylistTracklist({ const previewingId = usePreviewStore(s => s.previewingId); const previewAudioStarted = usePreviewStore(s => s.audioStarted); const showBitrate = useThemeStore(s => s.showBitrate); + const trackListCoversOn = useTrackListCoverArtEnabled('pages'); const { isDragging } = useDragDrop(); const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior(); @@ -232,6 +237,15 @@ export default function PlaylistTracklist({ const virtualItems = rowVirtualizer.getVirtualItems(); + const warmVisibleSongs = useMemo( + () => virtualItems.map(vi => displayedSongs[vi.index]), + [virtualItems, displayedSongs], + ); + + useWarmTrackListAlbumCovers(warmVisibleSongs, COVER_ARTIST_TOP_TRACK_CSS_PX, { + enabled: trackListCoversOn, + }); + let dropIndicatorY: number | null = null; if (isDragging && !isFiltered && dropTargetIdx) { const vi = virtualItems.find(v => v.index === dropTargetIdx.idx); @@ -334,16 +348,26 @@ export default function PlaylistTracklist({ ); }; - if (key === 'num') return ( -
- 0 ? ' bulk-check-visible' : ''}`} - onClick={e => { e.stopPropagation(); toggleAll(); }} - style={{ cursor: 'pointer' }} - /> - # -
- ); + if (key === 'num') { + const titleColIndex = visibleCols.findIndex(c => c.key === 'title'); + const titleCol = titleColIndex >= 0 ? visibleCols[titleColIndex] : undefined; + return ( +
+ 0 ? ' bulk-check-visible' : ''}`} + onClick={e => { e.stopPropagation(); toggleAll(); }} + style={{ cursor: 'pointer' }} + /> + # + {titleCol?.flex && ( +
startFlexColumnResize(e, titleColIndex, 1)} + /> + )} +
+ ); + } if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; return ( @@ -365,7 +389,12 @@ export default function PlaylistTracklist({ {label} {canSort && renderSortIndicator()}
- {hasNextCol &&
startResize(e, colIndex + 1, -1)} />} + {hasNextCol && ( +
startFlexColumnResize(e, colIndex, 1)} + /> + )}
); } diff --git a/src/features/playlist/pages/PlaylistDetail.tsx b/src/features/playlist/pages/PlaylistDetail.tsx index e50e5053..e8da3737 100644 --- a/src/features/playlist/pages/PlaylistDetail.tsx +++ b/src/features/playlist/pages/PlaylistDetail.tsx @@ -2,7 +2,7 @@ import { updatePlaylist } from '@/lib/api/subsonicPlaylists'; import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes'; import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; -import { useTracklistColumns, type ColDef } from '@/lib/hooks/useTracklistColumns'; +import { useTracklistColumns, type ColDef, TRACK_TITLE_FLEX_COL } from '@/lib/hooks/useTracklistColumns'; import { usePlayerStore } from '@/features/playback/store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { usePlaylistStore } from '@/features/playlist/store/playlistStore'; @@ -47,7 +47,7 @@ import { offlineActionPolicy } from '@/features/offline'; // ── Column configuration ────────────────────────────────────────────────────── const PL_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, - { key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true }, + { key: 'title', i18nKey: 'trackTitle', ...TRACK_TITLE_FLEX_COL, required: true }, { key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false }, { key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false }, { key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 120, required: false }, @@ -150,7 +150,7 @@ export default function PlaylistDetail() { // ── Column resize/visibility ────────────────────────────────────────────── const { colVisible, visibleCols, gridStyle, - startResize, toggleColumn, resetColumns, + startResize, startFlexColumnResize, toggleColumn, resetColumns, pickerOpen, setPickerOpen, pickerRef, tracklistRef, } = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns'); @@ -320,6 +320,7 @@ export default function PlaylistDetail() { setPickerOpen={setPickerOpen} pickerRef={pickerRef} startResize={startResize} + startFlexColumnResize={startFlexColumnResize} tracklistRef={tracklistRef} songs={songs} displayedSongs={displayedSongs} diff --git a/src/features/queue/components/QueueList.tsx b/src/features/queue/components/QueueList.tsx index 62cd0035..06705dc9 100644 --- a/src/features/queue/components/QueueList.tsx +++ b/src/features/queue/components/QueueList.tsx @@ -20,6 +20,8 @@ import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueI import type { TimelineDisplayRow } from '@/features/playback/utils/buildTimelineDisplayRows'; import { findTimelineScrollLocalIndex } from '@/features/playback/utils/buildTimelineDisplayRows'; import { playTimelineHistoryTrack } from '@/features/playback/utils/playTimelineHistoryTrack'; +import { OptionalQueueTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; type StartDrag = ( payload: { data: string; label: string }, @@ -60,6 +62,7 @@ export function QueueList({ startDrag, orbitAttributionLabel, luckyRolling, t, }: Props) { useSyncExternalStore(subscribeQueueResolver, getQueueResolverVersion); + const showCovers = useTrackListCoverArtEnabled('queue'); const usingTimeline = queueDisplayMode === 'timeline' && timelineRows != null; const rowCount = usingTimeline ? timelineRows.length : queue.length; @@ -68,7 +71,7 @@ export function QueueList({ const rowVirtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => queueListRef.current, - estimateSize: () => 52, + estimateSize: () => (showCovers ? 56 : 52), overscan: 10, getItemKey: i => { if (usingTimeline) return timelineRows[i]!.key; @@ -175,7 +178,7 @@ export function QueueList({ data-timeline-local-idx={localIndex} {...(isHistory ? { 'data-timeline-kind': 'history' } : {})} {...(absIdx != null ? { 'data-queue-idx': absIdx } : {})} - className={`queue-item ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === (absIdx != null ? 'queue-item' : 'song') && (absIdx != null ? contextMenu.queueIndex === absIdx : contextMenu.item === track) ? 'context-active' : ''}`} + className={`queue-item${showCovers ? ' queue-item--with-cover' : ''} ${isPlaying ? 'active' : ''} ${contextMenu.isOpen && contextMenu.type === (absIdx != null ? 'queue-item' : 'song') && (absIdx != null ? contextMenu.queueIndex === absIdx : contextMenu.item === track) ? 'context-active' : ''}`} onClick={() => { if (isHistory) { playHistoryRow(base?.serverId ?? track.serverId ?? '', track.id); @@ -217,6 +220,18 @@ export function QueueList({ }} style={{ ...(isPast && !isPlaying ? { opacity: 0.5 } : null), ...dragStyle }} > + {showCovers && ( + + )}
{isPlaying && } diff --git a/src/features/randomMix/components/RandomMixTrackRow.tsx b/src/features/randomMix/components/RandomMixTrackRow.tsx index 94a05f65..a542d3ff 100644 --- a/src/features/randomMix/components/RandomMixTrackRow.tsx +++ b/src/features/randomMix/components/RandomMixTrackRow.tsx @@ -6,6 +6,7 @@ import type { Track } from '@/lib/media/trackTypes'; import { previewInputFromSong, usePreviewStore } from '@/features/playback/store/previewStore'; import { useDragDrop } from '@/lib/dnd/DragDropContext'; import { formatRandomMixDuration } from '@/features/randomMix/utils/randomMixHelpers'; +import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; interface Props { song: SubsonicSong; @@ -125,6 +126,7 @@ export default function RandomMixTrackRow({ ? : } + {song.title}
diff --git a/src/features/search/components/PagedSongList.tsx b/src/features/search/components/PagedSongList.tsx index 67e6365a..4c362724 100644 --- a/src/features/search/components/PagedSongList.tsx +++ b/src/features/search/components/PagedSongList.tsx @@ -3,6 +3,9 @@ import React, { useRef } from 'react'; import SongRow, { SongListHeader } from '@/features/search/components/SongRow'; import { useInpageScrollSentinel } from '@/lib/hooks/useInpageScrollSentinel'; import InpageScrollSentinel from '@/ui/InpageScrollSentinel'; +import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '@/cover/layoutSizes'; +import { useWarmTrackListAlbumCovers } from '@/cover/useWarmTrackListAlbumCovers'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; interface Props { songs: SubsonicSong[]; @@ -24,6 +27,7 @@ interface Props { * is never painted over — issue #841). */ export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, showBpm }: Props) { + const trackListCoversOn = useTrackListCoverArtEnabled('pages'); const onLoadMoreRef = useRef(onLoadMore); // React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data. // eslint-disable-next-line react-hooks/refs @@ -35,6 +39,10 @@ export default function PagedSongList({ songs, hasMore, loadingMore, onLoadMore, rootMargin: '600px', }); + useWarmTrackListAlbumCovers(songs, COVER_ARTIST_TOP_TRACK_CSS_PX, { + enabled: trackListCoversOn && songs.length > 0, + }); + return ( <> diff --git a/src/features/search/components/SongRow.tsx b/src/features/search/components/SongRow.tsx index 95d16398..0d60ce22 100644 --- a/src/features/search/components/SongRow.tsx +++ b/src/features/search/components/SongRow.tsx @@ -12,6 +12,8 @@ import { useOrbitSongRowBehavior } from '@/features/orbit'; import { formatTrackTime } from '@/lib/format/formatDuration'; import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs'; import { tooltipAttrs } from '@/ui/tooltipAttrs'; +import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb'; +import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings'; interface Props { song: SubsonicSong; @@ -27,6 +29,7 @@ function SongRow({ song, showBpm }: Props) { const isCurrent = usePlayerStore(s => s.currentTrack?.id === song.id); const psyDrag = useDragDrop(); const { orbitActive, addTrackToOrbit } = useOrbitSongRowBehavior(); + const showCovers = useTrackListCoverArtEnabled('pages'); // In an orbit session both buttons collapse into the orbit-suggest / host-enqueue // path so we don't ship a queue replacement to every guest. @@ -51,7 +54,7 @@ function SongRow({ song, showBpm }: Props) { return (
{ e.preventDefault(); @@ -95,7 +98,12 @@ function SongRow({ song, showBpm }: Props) {
-
{song.title}
+
+ {showCovers && ( + + )} + {song.title} +
{artistRefs.map((a, i) => ( diff --git a/src/features/settings/components/AppearanceTab.tsx b/src/features/settings/components/AppearanceTab.tsx index 7e19eaa2..1c4fa79f 100644 --- a/src/features/settings/components/AppearanceTab.tsx +++ b/src/features/settings/components/AppearanceTab.tsx @@ -96,6 +96,20 @@ export function AppearanceTab() { onChange={theme.setEnablePlaylistCoverPhoto} />
+ +
+ +
): number { + const w = widths[c.key]; + if (typeof w === 'number' && w >= c.minWidth) return w; + if (c.defaultWidth >= c.minWidth) return c.defaultWidth; + return c.minWidth; +} + +function fixedColumnWidth(c: ColDef, widths: Record): number { + const w = widths[c.key]; + return typeof w === 'number' && w > 0 ? w : c.defaultWidth; +} + function loadPrefs( storageKey: string, columns: readonly ColDef[], @@ -35,6 +54,14 @@ function loadPrefs( if (durationCol && typeof widths.duration === 'number' && widths.duration < durationCol.minWidth) { widths.duration = defaultWidths.duration; } + // Flex title columns persisted `0` before resizable flex mins — seed a usable default. + columns.forEach(c => { + if (!c.flex || c.defaultWidth < c.minWidth) return; + const w = widths[c.key]; + if (typeof w !== 'number' || w < c.minWidth) { + widths[c.key] = c.defaultWidth; + } + }); return { widths, visible }; } catch { return { widths: defaultWidths, visible: defaultVisible }; @@ -72,12 +99,11 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri () => visibleCols .map(c => { - if (c.flex) return `minmax(${c.minWidth}px, 1fr)`; + if (c.flex) return `minmax(${flexColumnMin(c, colWidths)}px, 1fr)`; // Defensive fallback: a column added since the last persist would have // no saved width, leaving the grid template with `undefinedpx` and // collapsing the row visually until the user resets defaults. - const w = colWidths[c.key]; - return `${typeof w === 'number' && w > 0 ? w : c.defaultWidth}px`; + return `${fixedColumnWidth(c, colWidths)}px`; }) .join(' '), [visibleCols, colWidths], @@ -90,11 +116,7 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri const gapPx = 12; // --space-3 const boxPaddingH = 24; // var(--space-3) * 2 const colSum = visibleCols.reduce( - (s, c) => { - if (c.flex) return s + c.minWidth; - const w = colWidths[c.key]; - return s + (typeof w === 'number' && w > 0 ? w : c.defaultWidth); - }, + (s, c) => s + (c.flex ? flexColumnMin(c, colWidths) : fixedColumnWidth(c, colWidths)), 0, ); const gaps = Math.max(0, visibleCols.length - 1) * gapPx; @@ -117,9 +139,10 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri const visCols = visibleCols; // stable for the drag duration const colDef = visCols[colIndex]; const colKey = colDef.key; - const colMin = columns.find(c => c.key === colKey)!.minWidth; + const colDefFull = columns.find(c => c.key === colKey)!; + const colMin = colDefFull.minWidth; const startX = e.clientX; - const startW = colWidths[colKey]; + const startW = fixedColumnWidth(colDefFull, colWidths); let maxW = Infinity; const el = tracklistRef.current; @@ -132,9 +155,13 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri ? parseFloat(getComputedStyle(headerEl).columnGap) || 12 : 12; const totalGaps = (visCols.length - 1) * gapPx; + const widthsNow = colWidthsRef.current; const otherFixed = visCols .filter((_, i) => i !== colIndex) - .reduce((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0); + .reduce( + (s, c) => s + (c.flex ? flexColumnMin(c, widthsNow) : fixedColumnWidth(c, widthsNow)), + 0, + ); maxW = Math.max(colMin, containerW - totalGaps - otherFixed); } @@ -160,6 +187,64 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri [columns, visibleCols, colWidths, storageKey], ); + // Drag the flex (title) column min width — persisted in colWidths[key]. + const startFlexColumnResize = useCallback( + (e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => { + e.preventDefault(); + e.stopPropagation(); + + const visCols = visibleCols; + const colDef = visCols[colIndex]; + if (!colDef?.flex) return; + + const colKey = colDef.key; + const colMin = colDef.minWidth; + const startX = e.clientX; + const startW = flexColumnMin(colDef, colWidths); + + let maxW = Infinity; + const el = tracklistRef.current; + if (el) { + const style = getComputedStyle(el); + const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const containerW = el.clientWidth - paddingH; + const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null; + const gapPx = headerEl + ? parseFloat(getComputedStyle(headerEl).columnGap) || 12 + : 12; + const totalGaps = (visCols.length - 1) * gapPx; + const widthsNow = colWidthsRef.current; + const otherFixed = visCols + .filter((_, i) => i !== colIndex) + .reduce( + (s, c) => s + (c.flex ? flexColumnMin(c, widthsNow) : fixedColumnWidth(c, widthsNow)), + 0, + ); + maxW = Math.max(colMin, containerW - totalGaps - otherFixed); + } + + const onMove = (me: MouseEvent) => { + const delta = me.clientX - startX; + const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW); + setColWidths(prev => ({ ...prev, [colKey]: newW })); + }; + + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current); + }; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [visibleCols, colWidths, storageKey], + ); + const toggleColumn = useCallback( (key: string) => { const def = columns.find(c => c.key === key)!; @@ -193,6 +278,7 @@ export function useTracklistColumns(columns: readonly ColDef[], storageKey: stri visibleCols, gridStyle, startResize, + startFlexColumnResize, toggleColumn, resetColumns, pickerOpen, diff --git a/src/lib/library/advancedSearchLocal.test.ts b/src/lib/library/advancedSearchLocal.test.ts index a7f399df..dd2f0970 100644 --- a/src/lib/library/advancedSearchLocal.test.ts +++ b/src/lib/library/advancedSearchLocal.test.ts @@ -175,6 +175,32 @@ describe('runLocalAdvancedSearch', () => { expect(song.coverArt).toBe('al-42'); }); + it('trackToSong keeps hot albumId when rawJson omits or nulls albumId', () => { + const fromNull = trackToSong({ + serverId: 's1', + id: 't1', + title: 'T', + album: 'Diorama', + albumId: 'al-diorama', + durationSec: 100, + syncedAt: 0, + rawJson: { id: 't1', title: 'T', artist: 'Mol', album: 'Diorama', albumId: null, duration: 100 }, + }); + expect(fromNull.albumId).toBe('al-diorama'); + + const fromMissing = trackToSong({ + serverId: 's1', + id: 't2', + title: 'T2', + album: 'Diorama', + albumId: 'al-diorama', + durationSec: 100, + syncedAt: 0, + rawJson: { id: 't2', title: 'T2', artist: 'Mol', album: 'Diorama', duration: 100 }, + }); + expect(fromMissing.albumId).toBe('al-diorama'); + }); + it('trackToSong keeps resolved bpm and source over rawJson tag', () => { const song = trackToSong({ serverId: 's1', diff --git a/src/lib/library/trackDtoMapping.ts b/src/lib/library/trackDtoMapping.ts index 0bb8bb4a..bc1249a8 100644 --- a/src/lib/library/trackDtoMapping.ts +++ b/src/lib/library/trackDtoMapping.ts @@ -69,5 +69,9 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong { }; } if (t.serverId) merged.serverId = t.serverId; + const hotAlbumId = base.albumId?.trim(); + if (hotAlbumId && !merged.albumId?.trim()) { + merged.albumId = hotAlbumId; + } return merged; } diff --git a/src/locales/bg/settings.ts b/src/locales/bg/settings.ts index b9ea83a8..efa1f8e2 100644 --- a/src/locales/bg/settings.ts +++ b/src/locales/bg/settings.ts @@ -702,6 +702,10 @@ export const settings = { queueBehaviourTitle: 'Поведение на опашката', preservePlayNextOrder: 'Запази реда на „Изпълни следваща“', preservePlayNextOrderDesc: 'Новодобавените елементи чрез „Изпълни следваща“ се нареждат зад по-ранните, вместо да изскачат отпред.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Миниатюра на албума до песните в Песни, Търсене, плейлисти, любими, случаен микс и други списъци за преглед. Препоръчва се агресивно кеширане.', trackPreviewsTitle: 'Предварителни прослушвания', trackPreviewsToggle: 'Включи предварителните прослушвания', trackPreviewsDesc: 'Показвай вградени бутони за възпроизвеждане и прослушване в списъците с песни за бърз пример по средата на песента.', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 95cd5209..1d38e9be 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -635,6 +635,10 @@ export const settings = { queueBehaviourTitle: 'Warteschlangen-Verhalten', preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren', preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Kleines Albumcover neben Titeln in den Bereichen Titel, Suche, Playlists, Favoriten, Zufallsmix und anderen Übersichtslisten. Aggressives Caching wird empfohlen.', trackPreviewsTitle: 'Track-Vorschau', trackPreviewsToggle: 'Track-Vorschau aktivieren', trackPreviewsDesc: 'Inline Play- und Vorschau-Buttons in den Tracklisten anzeigen für eine kurze Hörprobe mitten im Song.', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index ba8500ec..66b6e67b 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -702,6 +702,10 @@ export const settings = { queueBehaviourTitle: 'Queue behaviour', preservePlayNextOrder: 'Preserve "Play Next" order', preservePlayNextOrderDesc: 'Newly added Play Next items queue up behind earlier ones instead of jumping in front.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Show a small album cover beside tracks on Tracks, Search, Playlists, Favorites, Random Mix, and other browse lists. Aggressive caching is recommended.', trackPreviewsTitle: 'Track Previews', trackPreviewsToggle: 'Enable track previews', trackPreviewsDesc: 'Show inline Play and Preview buttons in tracklists for a quick mid-song sample.', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 49a6662e..5fd2cfac 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -634,6 +634,10 @@ export const settings = { queueBehaviourTitle: 'Comportamiento de la cola', preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"', preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Miniatura del álbum junto a las canciones en Canciones, Buscar, listas de reproducción, favoritos, Mezcla Aleatoria y otras listas de exploración. Se recomienda la caché agresiva.', trackPreviewsTitle: 'Previsualizaciones de pistas', trackPreviewsToggle: 'Activar previsualizaciones', trackPreviewsDesc: 'Mostrar botones de Reproducir y Previsualización en las listas de pistas para una muestra rápida a mitad de canción.', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 82375dad..405ec1d5 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -622,6 +622,10 @@ export const settings = { queueBehaviourTitle: 'Comportement de la file', preservePlayNextOrder: "Préserver l'ordre « Lire ensuite »", preservePlayNextOrderDesc: 'Les nouveaux éléments « Lire ensuite » s\'ajoutent à la fin de la file existante au lieu de la doubler.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Miniature d\'album à côté des morceaux dans Titres, Recherche, Playlists, Favoris, Mix aléatoire et autres listes de navigation. Le cache agressif est recommandé.', trackPreviewsTitle: 'Aperçus de pistes', trackPreviewsToggle: 'Activer les aperçus de pistes', trackPreviewsDesc: 'Affiche les boutons Lecture et Aperçu dans les listes pour un court extrait au milieu du morceau.', diff --git a/src/locales/hu/settings.ts b/src/locales/hu/settings.ts index 5c8fda67..770427de 100644 --- a/src/locales/hu/settings.ts +++ b/src/locales/hu/settings.ts @@ -702,6 +702,10 @@ export const settings = { queueBehaviourTitle: 'Lejátszási sor viselkedése', preservePlayNextOrder: 'A „Játszás következőként" sorrend megőrzése', preservePlayNextOrderDesc: 'Az újonnan hozzáadott „Játszás következőként" elemek a korábbiak mögé sorakoznak fel, ahelyett, hogy eléjük ugranának.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Kis albumborító a számok mellett a Számok, Keresés, lejátszási listák, kedvencek, véletlen mix és egyéb böngészési listákban. Az agresszív gyorsítótárazás ajánlott.', trackPreviewsTitle: 'Számelőnézetek', trackPreviewsToggle: 'Számelőnézetek engedélyezése', trackPreviewsDesc: 'Soron belüli Lejátszás és Előnézet gombok megjelenítése a számlistákban egy gyors dal közbeni mintához.', diff --git a/src/locales/it/settings.ts b/src/locales/it/settings.ts index d30657ef..7fdef594 100644 --- a/src/locales/it/settings.ts +++ b/src/locales/it/settings.ts @@ -703,6 +703,10 @@ export const settings = { queueBehaviourTitle: 'Comportamento della coda', preservePlayNextOrder: 'Mantieni l\'ordine di "Riproduci dopo"', preservePlayNextOrderDesc: 'I nuovi elementi aggiunti con Riproduci dopo si accodano dietro quelli precedenti invece di passare avanti.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Miniatura dell\'album accanto ai brani in Brani, Cerca, playlist, preferiti, Mix casuale e altre liste di navigazione. Si consiglia la cache aggressiva.', trackPreviewsTitle: 'Anteprime brani', trackPreviewsToggle: 'Attiva anteprime brani', trackPreviewsDesc: 'Mostra i pulsanti Riproduci e Anteprima direttamente negli elenchi brani per un rapido assaggio a metà brano.', diff --git a/src/locales/ja/settings.ts b/src/locales/ja/settings.ts index fb4f09c4..a808bbbe 100644 --- a/src/locales/ja/settings.ts +++ b/src/locales/ja/settings.ts @@ -696,6 +696,10 @@ export const settings = { queueBehaviourTitle: 'キューの動作', preservePlayNextOrder: '"次に再生" の順序を保持', preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'トラック、検索、プレイリスト、お気に入り、ランダムミックスなどの閲覧リストで、各トラックの横に小さなアルバムカバーを表示します。アグレッシブキャッシュを有効にすることをおすすめします。', trackPreviewsTitle: 'トラックプレビュー', trackPreviewsToggle: 'トラックプレビューを有効化', trackPreviewsDesc: 'トラックリスト内に再生 / プレビューボタンを表示し、曲の途中をすばやく試聴します。', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 54e9d98c..1d9c165a 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -621,6 +621,10 @@ export const settings = { queueBehaviourTitle: 'Køoppførsel', preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge', preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Litet albumomslag ved siden av spor i Spor, Søk, spillelister, favoritter, tilfeldig miks og andre blafringslister. Aggressiv hurtigbufring anbefales.', trackPreviewsTitle: 'Sporforhåndsvisning', trackPreviewsToggle: 'Aktiver sporforhåndsvisning', trackPreviewsDesc: 'Vis innebygde Spill- og Forhåndsvisning-knapper i sporlister for en kort smakebit fra midten av sangen.', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index f024921e..9b9a5dc9 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -622,6 +622,10 @@ export const settings = { queueBehaviourTitle: 'Wachtrijgedrag', preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden', preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Klein albumhoesje naast nummers in Nummers, Zoeken, Playlists, Favorieten, willekeurige mix en andere bladerlijsten. Agressieve caching wordt aanbevolen.', trackPreviewsTitle: 'Track-voorvertoning', trackPreviewsToggle: 'Track-voorvertoning inschakelen', trackPreviewsDesc: 'Inline Play- en Voorvertoning-knoppen in trackslijsten tonen voor een korte sample midden in het nummer.', diff --git a/src/locales/pl/settings.ts b/src/locales/pl/settings.ts index 7d7a8ef7..bb6ff2f6 100644 --- a/src/locales/pl/settings.ts +++ b/src/locales/pl/settings.ts @@ -702,6 +702,10 @@ export const settings = { queueBehaviourTitle: 'Zachowanie kolejki', preservePlayNextOrder: 'Zachowaj kolejność "Odtwórz następne"', preservePlayNextOrderDesc: 'Nowo dodane elementy "Odtwórz następne" są kolejkowane za wcześniej dodanymi zamiast wskakiwać na sam początek.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Miniatura albumu obok utworów w Utworach, Szukaj, playlistach, ulubionych, losowym mikście i innych listach przeglądania. Zalecane jest agresywne buforowanie.', trackPreviewsTitle: 'Podglądy utworów', trackPreviewsToggle: 'Włącz podgląd utworów', trackPreviewsDesc: 'Pokazuj przyciski Odtwórz i Podejrzyj bezpośrednio na listach utworów, aby szybko posłuchać fragmentu ze środka utworu.', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 68b1c2c9..5b45c9e1 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -637,6 +637,10 @@ export const settings = { queueBehaviourTitle: 'Comportamentul cozii', preservePlayNextOrder: 'Prezervă ordinea "Redă următoarea"', preservePlayNextOrderDesc: 'Elementele Redă Următoarea noi adăugate sunt puse după cele adăugate mai devreme în loc să sară în față.', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: 'Miniatură album lângă piese în Piese, Caută, playlisturi, favorite, Mix Aleatoriu și alte liste de navigare. Se recomandă cache-ul agresiv.', trackPreviewsTitle: 'Previzualizări Piese', trackPreviewsToggle: 'Pornește previzualizările pieselor', trackPreviewsDesc: 'Arată butoanele de Redare și Pre-vizualizare în rând în listele de piese pentru o mostră rapidă din mijlocul piesei.', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 0f60b960..d73d1d1a 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -724,6 +724,10 @@ export const settings = { queueBehaviourTitle: 'Поведение очереди', preservePlayNextOrder: 'Сохранять порядок «Играть следующим»', preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.', + queueTrackListCovers: 'Обложки в очереди', + queueTrackListCoversSub: 'Миниатюра альбома напротив каждого трека в панели очереди, мини-плеере и «Далее» в полноэкранном режиме. Рекомендуется включение агрессивного кэширования.', + trackListCoverArtOnPages: 'Обложки в списках треков', + trackListCoverArtOnPagesSub: 'Миниатюра альбома в разделах «Треки», «Поиск», плейлистах, избранном, «Случайный микс» и других списках просмотра. Рекомендуется включение агрессивного кэширования.', trackPreviewsTitle: 'Превью треков', trackPreviewsToggle: 'Включить превью треков', trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 80817618..4de823f5 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -621,6 +621,10 @@ export const settings = { queueBehaviourTitle: '队列行为', preservePlayNextOrder: '保留"下一首播放"顺序', preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。', + queueTrackListCovers: 'Album art in queue', + queueTrackListCoversSub: 'Show a small album cover beside each track in the queue panel, mini player queue, and fullscreen Up next. Aggressive caching is recommended.', + trackListCoverArtOnPages: 'Album art in track lists', + trackListCoverArtOnPagesSub: '在曲目、搜索、播放列表、收藏夹、随机混音及其他浏览列表中,于曲目旁显示小号专辑封面。建议启用积极缓存。', trackPreviewsTitle: '曲目预览', trackPreviewsToggle: '启用曲目预览', trackPreviewsDesc: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。', diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts index f1e1337f..5f981135 100644 --- a/src/store/authStore.settings.test.ts +++ b/src/store/authStore.settings.test.ts @@ -50,6 +50,7 @@ describe('trivial pass-through setters', () => { ['setExcludeAudiobooks', 'excludeAudiobooks', true], ['setInfiniteQueueEnabled', 'infiniteQueueEnabled', true], ['setPreservePlayNextOrder', 'preservePlayNextOrder', true], + ['setQueueTrackListCovers', 'queueTrackListCovers', true], ['setShowArtistImages', 'showArtistImages', true], ['setArtistBrowseCreditMode', 'artistBrowseCreditMode', 'track'], ['setShowTrayIcon', 'showTrayIcon', false], diff --git a/src/store/authStore.ts b/src/store/authStore.ts index d847bc81..e3449ca5 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -110,6 +110,7 @@ export const useAuthStore = create()( queueNowPlayingCollapsed: false, queueDurationDisplayMode: 'total', queueDisplayMode: 'queue', + queueTrackListCovers: false, enableHiRes: false, hiResCrossfadeResampleHz: 44_100, audioOutputDevice: null, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 88be6295..05c557a6 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -264,6 +264,8 @@ export interface AuthState { * (`queue`), or full list centered on the current track with history above * and up-next below (`timeline`). */ queueDisplayMode: QueueDisplayMode; + /** Mini album thumbs beside each row in the queue panel, mini queue, and fs Up next. */ + queueTrackListCovers: boolean; /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ enableHiRes: boolean; @@ -456,6 +458,7 @@ export interface AuthState { setQueueNowPlayingCollapsed: (v: boolean) => void; setQueueDurationDisplayMode: (v: DurationMode) => void; setQueueDisplayMode: (v: QueueDisplayMode) => void; + setQueueTrackListCovers: (v: boolean) => void; setEnableHiRes: (v: boolean) => void; setHiResCrossfadeResampleHz: (v: HiResCrossfadeResampleHz) => void; setAudioOutputDevice: (v: string | null) => void; diff --git a/src/store/authUiAppearanceActions.ts b/src/store/authUiAppearanceActions.ts index c03081e7..5b1d5f99 100644 --- a/src/store/authUiAppearanceActions.ts +++ b/src/store/authUiAppearanceActions.ts @@ -31,6 +31,7 @@ export function createUiAppearanceActions(set: SetState): Pick< | 'setQueueNowPlayingCollapsed' | 'setQueueDurationDisplayMode' | 'setQueueDisplayMode' + | 'setQueueTrackListCovers' | 'setSidebarLyricsStyle' | 'setShowFullscreenLyrics' | 'setFsLyricsStyle' @@ -68,6 +69,7 @@ export function createUiAppearanceActions(set: SetState): Pick< setQueueNowPlayingCollapsed: (v) => set({ queueNowPlayingCollapsed: v }), setQueueDurationDisplayMode: (v) => set({ queueDurationDisplayMode: v }), setQueueDisplayMode: (v) => set({ queueDisplayMode: v }), + setQueueTrackListCovers: (v) => set({ queueTrackListCovers: v }), setSidebarLyricsStyle: (v) => set({ sidebarLyricsStyle: v }), setShowFullscreenLyrics: (v) => set({ showFullscreenLyrics: v }), setFsLyricsStyle: (v) => set({ fsLyricsStyle: v }), diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 460ce3f6..086742ed 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -72,6 +72,9 @@ interface ThemeState { setBackdropSources: (surface: BackdropSurface, sources: BackdropSourcePref[]) => void; enablePlaylistCoverPhoto: boolean; setEnablePlaylistCoverPhoto: (v: boolean) => void; + /** Mini album thumbs in browse tracklists (Tracks, Search, playlists, favorites, …). */ + trackListCoverArtOnPages: boolean; + setTrackListCoverArtOnPages: (v: boolean) => void; showBitrate: boolean; setShowBitrate: (v: boolean) => void; /** Compact (icon-only) vs. large action/toolbar buttons across detail pages and browse views. */ @@ -143,6 +146,8 @@ export const useThemeStore = create()( set((s) => ({ backdrops: { ...s.backdrops, [surface]: { ...s.backdrops[surface], sources } } })), enablePlaylistCoverPhoto: true, setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }), + trackListCoverArtOnPages: false, + setTrackListCoverArtOnPages: (v) => set({ trackListCoverArtOnPages: v }), showBitrate: true, setShowBitrate: (v) => set({ showBitrate: v }), buttonSize: 'large', diff --git a/src/styles/components/index.css b/src/styles/components/index.css index 828e1750..38d58d7b 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -17,6 +17,7 @@ @import './song-info-modal.css'; @import './share-queue-preview-modal.css'; @import './tracklist.css'; +@import './track-row-cover-thumb.css'; @import './modal.css'; @import './ui-modal.css'; @import './playback-delay-sleep-delayed-start-modal.css'; diff --git a/src/styles/components/track-row-cover-thumb.css b/src/styles/components/track-row-cover-thumb.css new file mode 100644 index 00000000..ffd2355b --- /dev/null +++ b/src/styles/components/track-row-cover-thumb.css @@ -0,0 +1,63 @@ +/* Track-list row thumbs only — scoped so player bar / queue-now-playing covers + never pick up the 32–40px sizing (see player-bar.css, queue-panel.css). */ + +.song-list-row .track-row-cover-thumb, +.queue-item .track-row-cover-thumb, +.mini-queue__item .track-row-cover-thumb, +.fsq-item .track-row-cover-thumb, +.track-info .track-row-cover-thumb, +.tracklist-mobile-main .track-row-cover-thumb { + flex-shrink: 0; + --track-list-cover-size: 32px; + width: var(--track-list-cover-size, 40px); + height: var(--track-list-cover-size, 40px); + border-radius: var(--radius-sm); + object-fit: cover; + background: var(--bg-hover); +} + +.song-list-row .track-row-cover-thumb--placeholder, +.queue-item .track-row-cover-thumb--placeholder, +.mini-queue__item .track-row-cover-thumb--placeholder, +.fsq-item .track-row-cover-thumb--placeholder, +.track-info .track-row-cover-thumb--placeholder, +.tracklist-mobile-main .track-row-cover-thumb--placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + opacity: 0.55; +} + +.mini-queue__item .track-row-cover-thumb--mini, +.fsq-item .track-row-cover-thumb--mini { + --track-list-cover-size: 32px; +} + +.queue-item--with-cover { + gap: var(--space-2); +} + +.queue-item--with-cover .queue-item-info { + min-width: 0; +} + +.mini-queue__item--with-cover { + gap: 8px; +} + +.mini-queue__item--with-cover .mini-queue__meta { + min-width: 0; +} + +.fsq-item--with-cover { + gap: 10px; +} + +.fsq-item--with-cover .fsq-item-info { + min-width: 0; +} + +.tracklist-mobile-row--with-cover .tracklist-mobile-main { + gap: 8px; +} diff --git a/src/styles/components/tracklist.css b/src/styles/components/tracklist.css index 95d252c5..8cef0c6f 100644 --- a/src/styles/components/tracklist.css +++ b/src/styles/components/tracklist.css @@ -261,6 +261,22 @@ display: flex; align-items: center; gap: 6px; + min-width: 0; +} + +/* Single-line title beside play/preview/cover controls (favorites, playlists, …) */ +.track-info-suggestion .track-title { + min-width: 0; + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + overflow-wrap: normal; + word-break: normal; +} + +.track-info-suggestion:has(.track-row-cover-thumb) { + min-height: 32px; } .playlist-suggestion-play-btn { diff --git a/src/styles/layout/player-bar.css b/src/styles/layout/player-bar.css index 32593d95..459cf926 100644 --- a/src/styles/layout/player-bar.css +++ b/src/styles/layout/player-bar.css @@ -98,6 +98,9 @@ html[data-platform="windows"] .player-bar.floating { .player-album-art { width: 52px; height: 52px; + min-width: 52px; + min-height: 52px; + max-width: none; border-radius: var(--radius-md); object-fit: cover; background: var(--bg-card); diff --git a/src/styles/layout/queue-panel.css b/src/styles/layout/queue-panel.css index 51799a34..e12aae4e 100644 --- a/src/styles/layout/queue-panel.css +++ b/src/styles/layout/queue-panel.css @@ -270,9 +270,14 @@ position: relative; } -.queue-current-cover img { +.queue-current-cover img, +.queue-current-cover-img, +.queue-current-cover [data-cover-provisional="true"] { width: 100%; height: 100%; + min-width: 0; + min-height: 0; + max-width: none; object-fit: cover; } diff --git a/src/styles/tracks/responsive.css b/src/styles/tracks/responsive.css index f736a8d4..f6101132 100644 --- a/src/styles/tracks/responsive.css +++ b/src/styles/tracks/responsive.css @@ -25,7 +25,7 @@ grid-template-columns: 64px minmax(0, 1.5fr) minmax(0, 1fr) 48px 64px; } - .song-list-row-cell:nth-child(4), /* album */ + .song-list-row-cell:nth-child(4), /* album — cover is inside title, not a column */ .song-list-row-cell:nth-child(5) { /* genre */ display: none; } diff --git a/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css b/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css index d11788f3..2191f4cf 100644 --- a/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css +++ b/src/styles/tracks/shared-songrow-used-by-tracks-hub-searchresults-advancedsearch.css @@ -63,6 +63,24 @@ .song-list-row-title { font-weight: 500; color: var(--row-text); + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.song-list-row-title-text { + min-width: 0; + flex: 1; +} + +.song-list-row--with-cover { + height: 56px; +} + +.song-list-row--with-cover .song-list-row-cover-thumb { + --track-list-cover-size: 36px; + flex-shrink: 0; } .song-list-row-genre {