mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(ui): album art thumbs in track lists via standard cover pipeline (#1280)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: 'Показвай вградени бутони за възпроизвеждане и прослушване в списъците с песни за бърз пример по средата на песента.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'トラックリスト内に再生 / プレビューボタンを表示し、曲の途中をすばやく試聴します。',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -724,6 +724,10 @@ export const settings = {
|
||||
queueBehaviourTitle: 'Поведение очереди',
|
||||
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
|
||||
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
|
||||
queueTrackListCovers: 'Обложки в очереди',
|
||||
queueTrackListCoversSub: 'Миниатюра альбома напротив каждого трека в панели очереди, мини-плеере и «Далее» в полноэкранном режиме. Рекомендуется включение агрессивного кэширования.',
|
||||
trackListCoverArtOnPages: 'Обложки в списках треков',
|
||||
trackListCoverArtOnPagesSub: 'Миниатюра альбома в разделах «Треки», «Поиск», плейлистах, избранном, «Случайный микс» и других списках просмотра. Рекомендуется включение агрессивного кэширования.',
|
||||
trackPreviewsTitle: 'Превью треков',
|
||||
trackPreviewsToggle: 'Включить превью треков',
|
||||
trackPreviewsDesc: 'Показывать встроенные кнопки воспроизведения и превью в списках треков для быстрого прослушивания фрагмента.',
|
||||
|
||||
@@ -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: '在曲目列表中显示内联播放与预览按钮,可快速试听歌曲中段。',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -110,6 +110,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
queueNowPlayingCollapsed: false,
|
||||
queueDurationDisplayMode: 'total',
|
||||
queueDisplayMode: 'queue',
|
||||
queueTrackListCovers: false,
|
||||
enableHiRes: false,
|
||||
hiResCrossfadeResampleHz: 44_100,
|
||||
audioOutputDevice: null,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user