feat(ui): album art thumbs in track lists via standard cover pipeline (#1280)

This commit is contained in:
cucadmuh
2026-07-13 04:16:55 +03:00
committed by GitHub
parent ad5d88d105
commit 9ca762dfd6
69 changed files with 1196 additions and 69 deletions
+7
View File
@@ -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
@@ -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);
@@ -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]
@@ -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();
+1
View File
@@ -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)',
],
},
{
+215
View File
@@ -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<TrackRowCoverThumbSize, number> = {
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 (
<div
className={`track-row-cover-thumb track-row-cover-thumb--placeholder${className ? ` ${className}` : ''}`}
aria-hidden="true"
>
{showIcon ? <Music size={Math.round(displayCssPx * 0.38)} /> : null}
</div>
);
}
function TrackRowCoverImage({
coverRef,
displayCssPx,
className,
observeScrollRootId,
ensurePriority,
}: {
coverRef: CoverArtRef;
displayCssPx: number;
className?: string;
observeScrollRootId?: string;
ensurePriority?: CoverPrefetchPriority;
}) {
return (
<CoverArtImage
coverRef={coverRef}
displayCssPx={displayCssPx}
surface="dense"
observeScrollRootId={observeScrollRootId}
ensurePriority={ensurePriority}
className={`track-row-cover-thumb${className ? ` ${className}` : ''}`}
alt=""
loading="lazy"
decoding="async"
/>
);
}
/**
* 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 <TrackRowCoverPlaceholder displayCssPx={displayCssPx} className={className} showIcon />;
}
return (
<TrackRowCoverImage
coverRef={coverRef}
displayCssPx={displayCssPx}
className={className}
observeScrollRootId={observeScrollRootId}
ensurePriority="high"
/>
);
}
/**
* 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 <TrackRowCoverPlaceholder displayCssPx={displayCssPx} className={className} />;
}
if (!albumId || !coverRef) {
return <TrackRowCoverPlaceholder displayCssPx={displayCssPx} className={className} showIcon />;
}
return (
<TrackRowCoverImage
coverRef={coverRef}
displayCssPx={displayCssPx}
className={className}
observeScrollRootId={observeScrollRootId}
/>
);
}
/** Routes to browse vs queue resolver based on `surface`. */
export function TrackRowCoverThumb({ surface, ...props }: Props) {
if (surface === 'queue') return <QueueTrackRowCoverThumb {...props} />;
return <BrowseTrackRowCoverThumb {...props} />;
}
/** 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 <TrackRowCoverThumb {...props} />;
}
/** Queue lists — respects queue toggle. */
export function OptionalQueueTrackRowCoverThumb(
props: Omit<Props, 'surface'>,
) {
return <OptionalTrackRowCoverThumb {...props} surface="queue" />;
}
/** Browse tracklists — respects pages toggle. */
export function OptionalBrowseTrackRowCoverThumb(
props: Omit<Props, 'surface'>,
) {
return <OptionalTrackRowCoverThumb {...props} surface="pages" />;
}
+2
View File
@@ -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) {
+2 -2
View File
@@ -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);
});
+4
View File
@@ -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;
+31 -1
View File
@@ -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-<id>_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');
});
});
+19 -1
View File
@@ -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-<albumId>_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 };
+19 -7
View File
@@ -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<CoverArtRef> {
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(
+4
View File
@@ -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);
});
});
+4
View File
@@ -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;
}
+79 -2
View File
@@ -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<CoverArtRef | null>(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<CoverArtRef | null>(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<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | 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<CoverArtRef | undefined>(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<CoverArtRef | null>(syncRef);
+11
View File
@@ -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;
}
@@ -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']);
});
});
+57
View File
@@ -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<SubsonicSong, 'albumId'>;
/**
* 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<SongAlbumSource>,
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]);
}
@@ -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();
});
});
@@ -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();
}
+53 -1
View File
@@ -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<string>();
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<void> {
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.
*/
@@ -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}
/>
@@ -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 (
<div className="tracklist-mobile">
{discNums.map(discNum => (
@@ -58,7 +62,7 @@ export function AlbumTrackListMobile({
return (
<div
key={song.id}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
className={`tracklist-mobile-row${showCovers ? ' tracklist-mobile-row--with-cover' : ''}${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
@@ -74,6 +78,7 @@ export function AlbumTrackListMobile({
) : (
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
)}
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" />
<span className="tracklist-mobile-title">{song.title}</span>
</div>
<span className="tracklist-mobile-duration">{formatLongDuration(song.duration)}</span>
@@ -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 (
<div key="title" className="track-info track-info-suggestion">
{showCovers ? (
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" className="song-list-row-cover-thumb" />
) : null}
<button
type="button"
className="playlist-suggestion-play-btn"
@@ -13,6 +13,7 @@ interface Props {
inSelectMode: boolean;
toggleAll: () => void;
startResize: (e: React.MouseEvent, colIndex: number, direction: 1 | -1) => void;
startFlexColumnResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void;
t: TFunction;
}
@@ -34,6 +35,7 @@ export function TracklistHeaderRow({
inSelectMode,
toggleAll,
startResize,
startFlexColumnResize,
t,
}: Props) {
const handleHeaderClick = (key: ColKey | string) => {
@@ -59,14 +61,22 @@ export function TracklistHeaderRow({
const isActive = canSort && sortKey === key;
if (key === 'num') {
const titleColIndex = visibleCols.findIndex(c => c.key === 'title');
const titleCol = titleColIndex >= 0 ? visibleCols[titleColIndex] : undefined;
return (
<div key={key} className="track-num">
<div key={key} className="track-num" style={{ position: 'relative' }}>
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
{titleCol?.flex && (
<div
className="col-resize-handle"
onMouseDown={e => startFlexColumnResize(e, titleColIndex, 1)}
/>
)}
</div>
);
}
@@ -93,7 +103,7 @@ export function TracklistHeaderRow({
{canSort && renderSortIndicator(key as SortKey)}
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
<div className="col-resize-handle" onMouseDown={e => startFlexColumnResize(e, colIndex, 1)} />
)}
</div>
);
@@ -1,4 +1,5 @@
import type { ColDef } from '@/lib/hooks/useTracklistColumns';
import { TRACK_TITLE_FLEX_COL } from '@/lib/hooks/useTracklistColumns';
export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
const parts: string[] = [];
@@ -9,7 +10,7 @@ export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitr
export const 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: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
@@ -10,6 +10,7 @@ import { formatTrackTime } from '@/lib/format/formatDuration';
import StarRating from '@/ui/StarRating';
import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb';
export interface FavoriteSongRowCallbacks {
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
@@ -97,6 +98,7 @@ function FavoriteSongRow({
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" />
<span className="track-title">{song.title}</span>
</div>
);
@@ -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<HTMLDivElement | null>;
tracklistRef: React.RefObject<HTMLDivElement | null>;
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<string, number>;
@@ -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 (
<>
<TracklistColumnPicker
@@ -201,8 +215,10 @@ export default function FavoritesSongsTracklist({
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') {
const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0;
const titleColIndex = visibleCols.findIndex(c => c.key === 'title');
const titleCol = titleColIndex >= 0 ? visibleCols[titleColIndex] : undefined;
return (
<div key="num" className="track-num">
<div key="num" className="track-num" style={{ position: 'relative' }}>
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
style={{ cursor: 'pointer' }}
@@ -216,6 +232,12 @@ export default function FavoritesSongsTracklist({
}}
/>
<span className="track-num-number">#</span>
{titleCol?.flex && (
<div
className="col-resize-handle"
onMouseDown={e => startFlexColumnResize(e, titleColIndex, 1)}
/>
)}
</div>
);
}
@@ -240,7 +262,12 @@ export default function FavoritesSongsTracklist({
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{canSort && getSortIndicator('title')}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
{hasNextCol && (
<div
className="col-resize-handle"
onMouseDown={e => startFlexColumnResize(e, colIndex, 1)}
/>
)}
</div>
);
}
+4 -3
View File
@@ -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}
@@ -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 }) => (
<button
key={`${track.id}:${absIdx}`}
className="fsq-item"
className={`fsq-item${showCovers ? ' fsq-item--with-cover' : ''}`}
onClick={() => {
playTrack(track, undefined, undefined, undefined, absIdx);
onClose();
}}
>
<span className="fsq-item-pos">{absIdx + 1}</span>
{showCovers && (
<OptionalQueueTrackRowCoverThumb
song={{
id: track.id,
albumId: track.albumId,
coverArt: track.coverArt,
discNumber: track.discNumber,
serverId: track.serverId,
title: track.title,
}}
size="mini"
className="track-row-cover-thumb--mini"
/>
)}
<span className="fsq-item-info">
<span className="fsq-item-title">{track.title}</span>
<span className="fsq-item-artist">{track.artist}</span>
@@ -3,6 +3,8 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { TFunction } from 'i18next';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import type { MiniSyncPayload, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
import { OptionalQueueTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb';
import { useTrackListCoverArtEnabled } from '@/cover/useTrackListCoverArtSettings';
// Stable initial rect so the virtualizer never re-initializes on re-render (an
// inline literal would be a new ref each render → render loop). Replaced by the
@@ -36,6 +38,7 @@ export function MiniQueue({
dropTarget, setDropTarget, dropTargetRef, startDrag, ctxIndex, setCtxMenu,
jumpTo, t,
}: Props) {
const showCovers = useTrackListCoverArtEnabled('queue');
// Virtualize so a multi-thousand-track queue keeps the mini window's DOM at
// O(visible rows). Scroll element is the OverlayScrollArea viewport.
// React Compiler incompatible-library rule: third-party hook/value the compiler cannot analyze; usage is correct.
@@ -43,7 +46,7 @@ export function MiniQueue({
const rowVirtualizer = useVirtualizer({
count: state.queue.length,
getScrollElement: () => queueScrollRef.current,
estimateSize: () => 40,
estimateSize: () => (showCovers ? 48 : 40),
overscan: 10,
getItemKey: i => `${state.queue[i].id}:${i}`,
initialRect: MINI_QUEUE_INITIAL_RECT,
@@ -99,7 +102,7 @@ export function MiniQueue({
data-index={i}
ref={rowVirtualizer.measureElement}
data-mq-idx={i}
className={`mini-queue__item${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
className={`mini-queue__item${showCovers ? ' mini-queue__item--with-cover' : ''}${i === state.queueIndex ? ' mini-queue__item--current' : ''}${ctxIndex === i ? ' mini-queue__item--ctx' : ''}`}
onClick={() => jumpTo(i)}
onContextMenu={(e) => {
e.preventDefault();
@@ -134,6 +137,19 @@ export function MiniQueue({
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)`, ...dragStyle }}
>
<span className="mini-queue__num">{i + 1}</span>
{showCovers && (
<OptionalQueueTrackRowCoverThumb
song={{
id: track.id,
albumId: track.albumId ?? '',
coverArt: track.coverArt,
serverId: state.queueServerId ?? undefined,
title: track.title,
}}
size="mini"
className="track-row-cover-thumb--mini"
/>
)}
<div className="mini-queue__meta">
<div className="mini-queue__title">{track.title}</div>
<div className="mini-queue__artist">{track.artist}</div>
@@ -9,6 +9,7 @@ import i18n from '@/lib/i18n';
import { formatTrackTime } from '@/lib/format/formatDuration';
import StarRating from '@/ui/StarRating';
import { PlaylistArtistCell } from '@/features/playlist/components/PlaylistArtistCell';
import { OptionalBrowseTrackRowCoverThumb } from '@/cover/TrackRowCoverThumb';
export interface PlaylistRowCallbacks {
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
@@ -102,6 +103,7 @@ function PlaylistRow({
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" />
<span className="track-title">{song.title}</span>
</div>
);
@@ -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 (
<div className="playlist-suggestions tracklist" data-preview-loc="suggestions">
@@ -170,6 +182,7 @@ export default function PlaylistSuggestions({
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" />
<span className="track-title">{song.title}</span>
</div>
);
@@ -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<React.SetStateAction<boolean>>;
pickerRef: React.RefObject<HTMLDivElement | null>;
startResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void;
startFlexColumnResize: (e: React.MouseEvent, colIndex: number, direction?: 1 | -1) => void;
tracklistRef: React.RefObject<HTMLDivElement | null>;
// 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 (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'num') {
const titleColIndex = visibleCols.findIndex(c => c.key === 'title');
const titleCol = titleColIndex >= 0 ? visibleCols[titleColIndex] : undefined;
return (
<div key="num" className="track-num" style={{ position: 'relative' }}>
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
{titleCol?.flex && (
<div
className="col-resize-handle"
onMouseDown={e => startFlexColumnResize(e, titleColIndex, 1)}
/>
)}
</div>
);
}
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
@@ -365,7 +389,12 @@ export default function PlaylistTracklist({
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSortActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator()}
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
{hasNextCol && (
<div
className="col-resize-handle"
onMouseDown={e => startFlexColumnResize(e, colIndex, 1)}
/>
)}
</div>
);
}
@@ -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}
+17 -2
View File
@@ -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 && (
<OptionalQueueTrackRowCoverThumb
song={{
id: track.id,
albumId: track.albumId,
coverArt: track.coverArt,
discNumber: track.discNumber,
serverId: track.serverId,
title: track.title,
}}
/>
)}
<div className="queue-item-info">
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
@@ -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({
? <Square size={9} fill="currentColor" strokeWidth={0} className="playlist-suggestion-preview-icon" />
: <ChevronRight size={14} className="playlist-suggestion-preview-icon playlist-suggestion-preview-icon-play" />}
</button>
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" />
<span className="track-title">{song.title}</span>
</div>
@@ -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 (
<>
<SongListHeader showBpm={showBpm} />
+10 -2
View File
@@ -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 (
<div
className={`song-list-row${isCurrent ? ' is-current' : ''}${showBpm ? ' song-list-row--with-bpm' : ''}`}
className={`song-list-row${isCurrent ? ' is-current' : ''}${showBpm ? ' song-list-row--with-bpm' : ''}${showCovers ? ' song-list-row--with-cover' : ''}`}
onDoubleClick={handlePlay}
onContextMenu={(e) => {
e.preventDefault();
@@ -95,7 +98,12 @@ function SongRow({ song, showBpm }: Props) {
<ListPlus size={14} />
</button>
</div>
<div className="song-list-row-cell song-list-row-title truncate" title={song.title}>{song.title}</div>
<div className="song-list-row-cell song-list-row-title truncate" title={song.title}>
{showCovers && (
<OptionalBrowseTrackRowCoverThumb song={song} size="dense" className="song-list-row-cover-thumb" />
)}
<span className="song-list-row-title-text truncate">{song.title}</span>
</div>
<div className="song-list-row-cell truncate" title={song.artist}>
{artistRefs.map((a, i) => (
<React.Fragment key={a.id ?? a.name ?? i}>
@@ -96,6 +96,20 @@ export function AppearanceTab() {
onChange={theme.setEnablePlaylistCoverPhoto}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.queueTrackListCovers')}
desc={t('settings.queueTrackListCoversSub')}
checked={auth.queueTrackListCovers}
onChange={auth.setQueueTrackListCovers}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.trackListCoverArtOnPages')}
desc={t('settings.trackListCoverArtOnPagesSub')}
checked={theme.trackListCoverArtOnPages}
onChange={theme.setTrackListCoverArtOnPages}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showBitrate')}
desc={t('settings.showBitrateSub')}
+97 -11
View File
@@ -10,6 +10,25 @@ export interface ColDef {
readonly flex?: boolean;
}
/** Shared flex title column — room for play/preview/cover controls + readable title text. */
export const TRACK_TITLE_FLEX_COL = {
minWidth: 240,
defaultWidth: 320,
flex: true as const,
};
function flexColumnMin(c: ColDef, widths: Record<string, number>): 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<string, number>): 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<number>(
(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<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
.reduce<number>(
(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<number>(
(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,
@@ -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',
+4
View File
@@ -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;
}
+4
View File
@@ -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: 'Показвай вградени бутони за възпроизвеждане и прослушване в списъците с песни за бърз пример по средата на песента.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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: 'トラックリスト内に再生 / プレビューボタンを表示し、曲の途中をすばやく試聴します。',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -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.',
+4
View File
@@ -724,6 +724,10 @@ export const settings = {
queueBehaviourTitle: 'Поведение очереди',
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
queueTrackListCovers: 'Обложки в очереди',
queueTrackListCoversSub: 'Миниатюра альбома напротив каждого трека в панели очереди, мини-плеере и «Далее» в полноэкранном режиме. Рекомендуется включение агрессивного кэширования.',
trackListCoverArtOnPages: 'Обложки в списках треков',
trackListCoverArtOnPagesSub: 'Миниатюра альбома в разделах «Треки», «Поиск», плейлистах, избранном, «Случайный микс» и других списках просмотра. Рекомендуется включение агрессивного кэширования.',
trackPreviewsTitle: 'Превью треков',
trackPreviewsToggle: 'Включить превью треков',
trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.',
+4
View File
@@ -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: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。',
+1
View File
@@ -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],
+1
View File
@@ -110,6 +110,7 @@ export const useAuthStore = create<AuthState>()(
queueNowPlayingCollapsed: false,
queueDurationDisplayMode: 'total',
queueDisplayMode: 'queue',
queueTrackListCovers: false,
enableHiRes: false,
hiResCrossfadeResampleHz: 44_100,
audioOutputDevice: null,
+3
View File
@@ -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;
+2
View File
@@ -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 }),
+5
View File
@@ -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<ThemeState>()(
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',
+1
View File
@@ -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';
@@ -0,0 +1,63 @@
/* Track-list row thumbs only scoped so player bar / queue-now-playing covers
never pick up the 3240px 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;
}
+16
View File
@@ -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 {
+3
View File
@@ -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);
+6 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
@@ -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 {