refactor(album): co-locate album feature into features/album

This commit is contained in:
Psychotoxical
2026-06-30 01:33:29 +02:00
parent 9ffc42688c
commit 2a425862ae
76 changed files with 387 additions and 347 deletions
@@ -0,0 +1,11 @@
import { api } from '@/api/subsonicClient';
import type { AlbumInfo } from '@/api/subsonicTypes';
export async function getAlbumInfo2(albumId: string): Promise<AlbumInfo | null> {
try {
const data = await api<{ albumInfo: AlbumInfo }>('getAlbumInfo2.view', { id: albumId });
return data.albumInfo ?? null;
} catch {
return null;
}
}
+237
View File
@@ -0,0 +1,237 @@
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '@/features/album/hooks/useNavigateToAlbum';
import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { isOfflinePinComplete } from '@/features/offline';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { coverStorageKeyFromRef } from '@/cover/storageKeys';
import type { CoverPrefetchPriority } from '@/cover/types';
import { COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import { resolveCoverDisplayTier } from '@/cover/tiers';
import { acquireUrl } from '@/utils/imageCache/urlPool';
import { OpenArtistRefInline } from '@/features/artist';
import { fetchAlbumTracks, playAlbum, playAlbumShuffled } from '@/utils/playback/playAlbum';
import { useLongPressAction } from '@/hooks/useLongPressAction';
import { LongPressWaveOverlay } from '@/components/LongPressWaveOverlay';
import { useDragDrop } from '@/contexts/DragDropContext';
import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { coverServerScopeForServerId } from '@/cover/serverScope';
import { appendServerQuery } from '@/utils/navigation/detailServerScope';
interface AlbumCardProps {
album: SubsonicAlbum;
selected?: boolean;
selectionMode?: boolean;
onToggleSelect?: (id: string, opts?: { shiftKey?: boolean }) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean;
/** Layout-native cover square width in CSS px (from parent grid). */
displayCssPx?: number;
/** @deprecated Use displayCssPx — kept for call-site transition only */
artworkSize?: number;
/** Appended to `/album/:id`, e.g. `lossless=1`. */
linkQuery?: string;
/** In-page scroll viewport (`VirtualCardGrid` `scrollRootId`) for cover IO priority. */
observeScrollRootId?: string;
/** `high` for bounded grids (Random Albums, …) — skip defer-until-visible. */
ensurePriority?: CoverPrefetchPriority;
/** Artist/detail grids: API `coverArt` is enough — skip per-card library_resolve IPC. */
libraryResolve?: boolean;
}
function AlbumCard({
album,
selected,
selectionMode,
onToggleSelect,
showRating = false,
selectedAlbums = [],
disableArtwork = false,
displayCssPx = COVER_DENSE_GRID_MIN_CELL_CSS_PX,
artworkSize: _artworkSize,
observeScrollRootId,
ensurePriority,
linkQuery,
libraryResolve = false,
}: AlbumCardProps) {
const { t } = useTranslation();
const { isHolding, pressBind } = useLongPressAction({
onShortPress: () => playAlbum(album.id, album.serverId ? { serverId: album.serverId } : undefined),
onLongPress: () => playAlbumShuffled(album.id, album.serverId ? { serverId: album.serverId } : undefined),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const activeServerId = useAuthStore(s => s.activeServerId ?? '');
const offlineServerId = album.serverId ?? activeServerId;
const localEntries = useLocalPlaybackStore(s => s.entries);
const isOffline = isOfflinePinComplete(album.id, offlineServerId);
const albumLinkQuery = useMemo(
() => appendServerQuery(linkQuery, album.serverId),
[linkQuery, album.serverId],
);
void localEntries;
const psyDrag = useDragDrop();
const coverServerScope = useMemo(
() => coverServerScopeForServerId(album.serverId),
[album.serverId],
);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, coverServerScope, { libraryResolve });
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
return coverStorageKeyFromRef(coverRef, tier);
}, [coverRef, displayCssPx]);
const isNewAlbum = isAlbumRecentlyAdded(album.created);
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
const handleClick = (opts?: { shiftKey?: boolean }) => {
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigateToAlbum(album.id, { search: albumLinkQuery });
};
return (
<div
className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
onClick={e => handleClick({ shiftKey: e.shiftKey })}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${artistLabel}`}
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedAlbums.length > 0) {
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
} else {
openContextMenu(e.clientX, e.clientY, album, 'album');
}
}}
onMouseDown={e => {
if (selectionMode || e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
const coverUrl = dragCoverKey ? acquireUrl(dragCoverKey) ?? undefined : undefined;
psyDrag.startDrag({ data: JSON.stringify({ type: 'album', id: album.id, name: album.name }), label: album.name, coverUrl }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<div className="album-card-cover">
{!disableArtwork && coverRef ? (
<CoverArtImage
coverRef={coverRef}
displayCssPx={displayCssPx}
surface="dense"
alt={`${album.name} Cover`}
loading="eager"
decoding="async"
observeScrollRootId={observeScrollRootId}
ensurePriority={ensurePriority}
/>
) : (
<div className="album-card-cover-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</div>
)}
{(isNewAlbum || (isOffline && !selectionMode)) && (
<div className="album-card-cover-badges-tr">
{isNewAlbum && (
<div className="album-card-new-badge" aria-label={t('common.new', 'New')}>
{t('common.new', 'New')}
</div>
)}
{isOffline && !selectionMode && (
<div className="album-card-offline-badge" aria-label="Offline available">
<HardDriveDownload size={12} />
</div>
)}
</div>
)}
{selectionMode && (
<div className={`album-card-select-check${selected ? ' album-card-select-check--on' : ''}`}>
{selected && <Check size={14} strokeWidth={3} />}
</div>
)}
{!selectionMode && (
<div className="album-card-play-overlay">
<button
className="album-card-details-btn long-press-play-btn"
{...pressBind}
aria-label={`${t('hero.playAlbumTooltip')}${album.name}`}
data-tooltip={t('hero.playAlbumTooltip')}
data-tooltip-pos="top"
>
<LongPressWaveOverlay active={isHolding} />
<span className="long-press-play-btn__icon">
<Play size={15} fill="currentColor" />
</span>
</button>
<button
className="album-card-details-btn"
onClick={async e => {
e.stopPropagation();
try {
const tracks = await fetchAlbumTracks(
album.id,
offlineServerId || undefined,
);
if (tracks.length > 0) enqueue(tracks);
} catch {
// Unavailable offline or network failure — silent on hover action
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
data-tooltip={t('contextMenu.enqueueAlbum')}
data-tooltip-pos="top"
>
<ListPlus size={15} />
</button>
</div>
)}
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">
<OpenArtistRefInline
refs={artistRefs}
fallbackName={artistLabel}
onGoArtist={id => navigate(`/artist/${id}`)}
as="none"
linkTag="span"
linkClassName="track-artist-link"
/>
</p>
{album.year && <p className="album-card-year">{album.year}</p>}
{showRating && (album.userRating ?? 0) > 0 && (
<div className="album-card-rating-row">
<span className="album-card-rating-stars">
{'★'.repeat(album.userRating!)}{'☆'.repeat(5 - album.userRating!)}
</span>
</div>
)}
</div>
</div>
);
}
export default memo(AlbumCard);
@@ -0,0 +1,96 @@
import React from 'react';
import { ListPlus, Search, X } from 'lucide-react';
import type { TFunction } from 'i18next';
import { useSelectionStore } from '@/store/selectionStore';
import { AddToPlaylistSubmenu } from '@/components/ContextMenu';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
interface Props {
filterText: string;
setFilterText: (v: string) => void;
inSelectMode: boolean;
selectedCount: number;
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
t: TFunction;
actionPolicy?: OfflineActionPolicy;
}
/**
* Toolbar above the album tracklist. The filter input narrows the
* visible songs by title/artist; the bulk-action cluster only appears
* while the global selection store has at least one item picked.
*
* The "Add to playlist" picker is a portal-aware popover (closes on
* outside mousedown, see AlbumDetail page effect) so the parent owns
* `showPlPicker` to coordinate that close with selection clears.
*/
export function AlbumDetailToolbar({
filterText,
setFilterText,
inSelectMode,
selectedCount,
showPlPicker,
setShowPlPicker,
t,
actionPolicy,
}: Props) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
return (
<div className="album-track-toolbar">
<div className="album-track-toolbar-filter">
<Search size={16} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }} />
<input
className="input-search"
style={{ width: '100%', paddingRight: filterText ? 28 : undefined }}
placeholder={t('albumDetail.filterSongs')}
value={filterText}
onChange={e => setFilterText(e.target.value)}
/>
{filterText && (
<button
style={{ position: 'absolute', right: 6, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setFilterText('')}
aria-label="Clear filter"
>
<X size={14} />
</button>
)}
</div>
<div className="album-track-toolbar-actions">
{inSelectMode && (
<>
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
{policy.canAddToPlaylist && (
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
)}
</div>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => useSelectionStore.getState().clearAll()}
>
<X size={13} />
{t('common.bulkClear')}
</button>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import AlbumHeader from '@/features/album/components/AlbumHeader';
import type { SubsonicSong } from '@/api/subsonicTypes';
const navigate = vi.fn();
vi.mock('react-router-dom', async importActual => {
const actual = await importActual<typeof import('react-router-dom')>();
return { ...actual, useNavigate: () => navigate };
});
// Genre-unrelated dependencies — stub so the test stays focused on the meta row.
vi.mock('@/cover/useLibraryCoverRef', () => ({ useAlbumCoverRef: () => undefined }));
vi.mock('@/cover/lightbox', () => ({ useCoverLightboxSrc: () => ({ open: vi.fn(), lightbox: null }) }));
vi.mock('@/features/album/hooks/useAlbumDetailBack', () => ({ useAlbumDetailBack: () => vi.fn() }));
vi.mock('@/hooks/useIsMobile', () => ({ useIsMobile: () => false }));
vi.mock('@/store/themeStore', () => ({ useThemeStore: () => false }));
vi.mock('@/components/StarRating', () => ({ default: () => null }));
vi.mock('@/features/artist', () => ({ OpenArtistRefInline: () => null }));
vi.mock('@/cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
function baseProps() {
return {
headerArtistRefs: [],
songs: [] as SubsonicSong[],
resolvedCoverUrl: null,
isStarred: false,
downloadProgress: null,
offlineStatus: 'none' as const,
offlineProgress: null,
bio: null,
bioOpen: false,
onToggleStar: vi.fn(),
onDownload: vi.fn(),
onCacheOffline: vi.fn(),
onRemoveOffline: vi.fn(),
onPlayAll: vi.fn(),
onEnqueueAll: vi.fn(),
onBio: vi.fn(),
onCloseBio: vi.fn(),
entityRatingValue: 0,
onEntityRatingChange: vi.fn(),
entityRatingSupport: 'unknown' as const,
};
}
const albumInfo = (over: Record<string, unknown> = {}) => ({
id: 'al1', name: 'Album', artist: 'Artist', artistId: 'a1', ...over,
});
describe('AlbumHeader genres', () => {
it('shows the primary genre inline and the rest in a cursor menu', async () => {
navigate.mockClear();
const user = userEvent.setup();
renderWithProviders(
<AlbumHeader
{...baseProps()}
info={albumInfo({ genres: [{ name: 'Power Metal' }, { name: 'Rock' }] })}
/>,
);
// Primary genre is a link; the extra genre stays hidden until the menu opens.
expect(screen.getByRole('button', { name: 'More albums in Power Metal' })).toBeInTheDocument();
expect(screen.queryByText('Rock')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Show all genres' }));
// The menu lists only the remaining genres — the primary is not repeated.
expect(screen.getAllByText('Power Metal')).toHaveLength(1);
const rock = screen.getByText('Rock');
await user.click(rock);
expect(navigate).toHaveBeenCalledWith('/genres/Rock', { state: { returnTo: '/album/al1' } });
});
it('unions track-level genres after the album genres', async () => {
const user = userEvent.setup();
renderWithProviders(
<AlbumHeader
{...baseProps()}
songs={[{ id: 't1', duration: 100, genres: [{ name: 'Heavy Metal' }] } as unknown as SubsonicSong]}
info={albumInfo({ genres: [{ name: 'Power Metal' }] })}
/>,
);
// Album has one genre, the track adds another → +1 chip, extra genre in the menu.
await user.click(screen.getByRole('button', { name: 'Show all genres' }));
expect(screen.getByText('Heavy Metal')).toBeInTheDocument();
});
it('shows no +N control for a single genre', () => {
renderWithProviders(
<AlbumHeader {...baseProps()} info={albumInfo({ genres: [{ name: 'Power Metal' }] })} />,
);
expect(screen.getByRole('button', { name: 'More albums in Power Metal' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show all genres' })).not.toBeInTheDocument();
});
it('falls back to splitting the legacy genre string', () => {
renderWithProviders(
<AlbumHeader {...baseProps()} info={albumInfo({ genre: 'Rock; Metal' })} />,
);
expect(screen.getByRole('button', { name: 'More albums in Rock' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show all genres' })).toBeInTheDocument();
});
it('opens via keyboard with focus inside the menu, arrow-navigates, and restores focus on close', async () => {
const user = userEvent.setup();
renderWithProviders(
<AlbumHeader
{...baseProps()}
info={albumInfo({ genres: [{ name: 'Power Metal' }, { name: 'Rock' }, { name: 'Jazz' }] })}
/>,
);
const more = screen.getByRole('button', { name: 'Show all genres' });
more.focus();
// Enter activates the chip from the keyboard — focus must land on the first item.
await user.keyboard('{Enter}');
const items = screen.getAllByRole('menuitem');
expect(items[0]).toHaveFocus();
await user.keyboard('{ArrowDown}');
expect(items[1]).toHaveFocus();
// Escape closes the menu and returns focus to the +N trigger.
await user.keyboard('{Escape}');
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(more).toHaveFocus();
});
});
@@ -0,0 +1,601 @@
import type { EntityRatingSupportLevel, SubsonicItemGenre, SubsonicOpenArtistRef, SubsonicSong } from '@/api/subsonicTypes';
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { Play, Heart, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { useCoverLightboxSrc } from '@/cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAlbumDetailBack } from '@/features/album/hooks/useAlbumDetailBack';
import { useThemeStore } from '@/store/themeStore';
import StarRating from '@/components/StarRating';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import { showToast } from '@/utils/ui/toast';
import { isAlbumRecentlyAdded } from '@/features/album/utils/albumRecency';
import { formatLongDuration } from '@/utils/format/formatDuration';
import { formatMb } from '@/utils/format/formatBytes';
import { sanitizeHtml } from '@/utils/sanitizeHtml';
import { OpenArtistRefInline } from '@/features/artist';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { deriveAlbumGenreTags } from '@/utils/library/genreTags';
import { genreColor } from '@/utils/library/genreColor';
/** True when the album artist label means "no single artist" — `getArtistInfo`
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
*/
function isVariousArtistsLabel(name: string | undefined | null): boolean {
if (!name) return false;
const trimmed = name.trim().toLowerCase();
return (
trimmed === 'various artists' ||
trimmed === 'various' ||
trimmed === 'va' ||
trimmed === 'multiple artists' ||
trimmed === 'verschiedene interpreten' ||
trimmed === 'verschiedene'
);
}
function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
const { t } = useTranslation();
return createPortal(
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
<div className="modal-content bio-modal" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
<div className="bio-modal-body">
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
</div>
</div>
</div>,
document.body
);
}
/** Cursor-anchored genre list (context-menu style) for the "+N" chip. */
function GenreMenu({
genres, pos, onPick, onClose,
}: {
genres: string[];
pos: { x: number; y: number };
onPick: (genre: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const [coords, setCoords] = useState(pos);
// Clamp into the viewport once the menu has measured its own size, then move
// focus to the first genre so keyboard users land inside the menu.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pad = 8;
setCoords({
x: Math.max(pad, Math.min(pos.x, window.innerWidth - rect.width - pad)),
y: Math.max(pad, Math.min(pos.y, window.innerHeight - rect.height - pad)),
});
el.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}, [pos]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
const items = Array.from(
ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
);
if (items.length === 0) return;
const focusAt = (i: number) => items[(i + items.length) % items.length].focus();
const at = items.indexOf(document.activeElement as HTMLElement);
if (e.key === 'ArrowDown') { e.preventDefault(); focusAt(at + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); focusAt(at < 0 ? -1 : at - 1); }
else if (e.key === 'Home') { e.preventDefault(); focusAt(0); }
else if (e.key === 'End') { e.preventDefault(); focusAt(items.length - 1); }
};
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
window.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onDown);
};
}, [onClose]);
return createPortal(
<div
ref={ref}
className="genre-menu"
role="menu"
aria-label={t('albumDetail.genresModalTitle')}
style={{ left: coords.x, top: coords.y }}
>
{genres.map(g => (
<button
key={g}
type="button"
role="menuitem"
className="genre-menu-item"
style={{ '--genre-color': genreColor(g) } as CSSProperties}
onClick={() => onPick(g)}
>
{g}
</button>
))}
</div>,
document.body,
);
}
interface AlbumInfo {
id: string;
name: string;
artist: string;
artistId: string;
year?: number;
genre?: string;
/** OpenSubsonic atomic genres — preferred over the legacy `genre` string. */
genres?: SubsonicItemGenre[];
coverArt?: string;
recordLabel?: string;
created?: string;
}
interface AlbumHeaderProps {
info: AlbumInfo;
/** OpenSubsonic album credits (derived from album + songs). */
headerArtistRefs: SubsonicOpenArtistRef[];
songs: SubsonicSong[];
coverArtId?: string;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
offlineStatus: 'none' | 'queued' | 'downloading' | 'cached';
offlineProgress: { done: number; total: number } | null;
bio: string | null;
bioOpen: boolean;
onToggleStar: () => void;
onDownload: () => void;
onCacheOffline: () => void;
onRemoveOffline: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onShuffleAll?: () => void;
onBio: () => void;
onCloseBio: () => void;
entityRatingValue: number;
onEntityRatingChange: (rating: number) => void;
/** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */
entityRatingSupport: EntityRatingSupportLevel | 'unknown';
/** Offline browse action gates (favorites, download, cache, bio, ratings). */
actionPolicy?: OfflineActionPolicy;
}
export default function AlbumHeader({
info,
headerArtistRefs,
songs,
coverArtId,
resolvedCoverUrl,
isStarred,
downloadProgress,
offlineStatus,
offlineProgress,
bio,
bioOpen,
onToggleStar,
onDownload,
onCacheOffline,
onRemoveOffline,
onPlayAll,
onEnqueueAll,
onShuffleAll,
onBio,
onCloseBio,
entityRatingValue,
onEntityRatingChange,
entityRatingSupport,
actionPolicy,
}: AlbumHeaderProps) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
const { t } = useTranslation();
const navigate = useNavigate();
const goBack = useAlbumDetailBack();
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
alt: `${info.name} Cover`,
});
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
const isNewAlbum = isAlbumRecentlyAdded(info.created);
const showBioButton = !isVariousArtistsLabel(info.artist);
const genreTags = deriveAlbumGenreTags(info, songs);
const [genreMenuPos, setGenreMenuPos] = useState<{ x: number; y: number } | null>(null);
const genreMoreRef = useRef<HTMLButtonElement>(null);
const goToGenre = (genre: string) => {
setGenreMenuPos(null);
navigate(`/genres/${encodeURIComponent(genre)}`, { state: { returnTo: `/album/${info.id}` } });
};
const handleShareAlbum = async () => {
try {
const ok = await copyEntityShareLink('album', info.id);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
}
};
return (
<>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
{lightbox}
{genreMenuPos && (
<GenreMenu
genres={genreTags.slice(1)}
pos={genreMenuPos}
onPick={goToGenre}
onClose={() => {
setGenreMenuPos(null);
genreMoreRef.current?.focus();
}}
/>
)}
<div className="album-detail-header">
{resolvedCoverUrl && enableCoverArtBackground && (
<>
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
<div className="album-detail-overlay" aria-hidden="true" />
</>
)}
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={goBack}>
<ChevronLeft size={16} /> {t('albumDetail.back')}
</button>
<div className="album-detail-hero">
{coverRef ? (
<button
className="album-detail-cover-btn"
onClick={openLightbox}
data-tooltip={t('albumDetail.enlargeCover')}
aria-label={`${info.name} ${t('albumDetail.enlargeCover')}`}
>
<CoverArtImage
className="album-detail-cover"
coverRef={coverRef}
displayCssPx={400}
surface="sparse"
alt={`${info.name} Cover`}
/>
</button>
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
<div className="album-detail-meta">
{isNewAlbum && (
<span className="badge album-detail-badge">{t('common.new', 'New')}</span>
)}
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<OpenArtistRefInline
refs={headerArtistRefs}
fallbackName={info.artist}
onGoArtist={id => navigate(`/artist/${id}`)}
linkClassName="album-detail-artist-link"
/>
</p>
{genreTags.length > 0 && (
<div className="album-detail-genre-row">
<button
className="album-detail-genre-pill"
data-tooltip={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
aria-label={t('albumDetail.moreGenreAlbums', { genre: genreTags[0] })}
onClick={() => goToGenre(genreTags[0])}
>
{genreTags[0]}
</button>
{genreTags.length > 1 && (
<button
ref={genreMoreRef}
className="album-detail-genre-pill"
data-tooltip={t('albumDetail.showAllGenres')}
aria-label={t('albumDetail.showAllGenres')}
aria-haspopup="menu"
aria-expanded={genreMenuPos != null}
onClick={e => {
// Keyboard activation (Enter/Space) reports clientX/Y = 0 →
// anchor below the chip instead of the viewport corner.
if (e.detail === 0) {
const r = e.currentTarget.getBoundingClientRect();
setGenreMenuPos({ x: r.left, y: r.bottom + 4 });
} else {
setGenreMenuPos({ x: e.clientX, y: e.clientY });
}
}}
>
{`+${genreTags.length - 1}`}
</button>
)}
</div>
)}
<div className="album-detail-info">
{info.year && <span>{info.year}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatLongDuration(totalDuration)}</span>
{formatLabel && <span>· {formatLabel}</span>}
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
>
{info.recordLabel}
</button>
</>
)}
</div>
<div className="album-detail-entity-rating">
<span className="album-detail-entity-rating-label">{t('entityRating.albumShort')}</span>
<StarRating
value={entityRatingValue}
onChange={onEntityRatingChange}
disabled={!policy.canRate || entityRatingSupport === 'track_only'}
labelKey="entityRating.albumAriaLabel"
/>
</div>
{isMobile ? (
<div className="album-detail-actions-mobile">
{/* Row 1 — Primary actions */}
<div className="album-actions-row album-actions-row--primary">
<button
className="album-icon-btn album-icon-btn--play"
onClick={onPlayAll}
aria-label={t('albumDetail.playAll')}
data-tooltip={t('albumDetail.playAll')}
>
<Play size={24} fill="currentColor" />
</button>
<button
className="album-icon-btn album-icon-btn--queue"
onClick={onEnqueueAll}
aria-label={t('albumDetail.enqueue')}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={20} />
</button>
</div>
{/* Row 2 — Secondary actions */}
<div className="album-actions-row album-actions-row--secondary">
{policy.canFavorite && (
<button
className={`album-icon-btn album-icon-btn--sm${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
aria-label={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
className="album-icon-btn album-icon-btn--sm"
type="button"
onClick={handleShareAlbum}
aria-label={t('albumDetail.shareAlbum')}
data-tooltip={t('albumDetail.shareAlbum')}
>
<Share2 size={16} />
</button>
{showBioButton && policy.canShowBio && (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onBio}
aria-label={t('albumDetail.artistBio')}
data-tooltip={t('albumDetail.artistBio')}
>
<Highlighter size={16} />
</button>
)}
{policy.canDownload && (
downloadProgress !== null ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Download size={14} />
<span className="album-icon-btn-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onDownload}
aria-label={t('albumDetail.download')}
data-tooltip={t('albumDetail.download')}
>
<Download size={16} />
</button>
)
)}
{policy.canPinOffline && (
offlineStatus === 'downloading' ? (
<div className="album-icon-btn album-icon-btn--sm album-icon-btn--progress">
<Loader2 size={14} className="spin" />
</div>
) : offlineStatus === 'queued' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
</button>
) : offlineStatus === 'cached' ? (
<button
className="album-icon-btn album-icon-btn--sm album-icon-btn--active"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
</button>
) : (
<button
className="album-icon-btn album-icon-btn--sm"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
</button>
)
)}
</div>
</div>
) : (
<div className="album-detail-actions compact-action-bar">
<div className="album-detail-actions-primary">
<button
className="btn btn-primary"
id="album-play-all-btn"
onClick={onPlayAll}
{...tooltipAttrs(t('albumDetail.playTooltip'))}
>
<Play size={15} /> <span className="compact-btn-label">{t('common.play', 'Reproducir')}</span>
</button>
{onShuffleAll && (
<button
className="btn btn-surface"
onClick={onShuffleAll}
data-tooltip={t('playlists.shuffle', 'Shuffle')}
>
<Shuffle size={16} />
</button>
)}
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} />
</button>
{policy.canFavorite && (
<button
className={`btn btn-surface${isStarred ? ' is-starred' : ''}`}
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
<button
type="button"
className="btn btn-surface"
onClick={handleShareAlbum}
aria-label={t('albumDetail.shareAlbum')}
data-tooltip={t('albumDetail.shareAlbum')}
>
<Share2 size={16} />
</button>
</div>
{showBioButton && policy.canShowBio && (
<button
className="btn btn-surface"
id="album-bio-btn"
onClick={onBio}
{...tooltipAttrs(t('albumDetail.artistBioTooltip'))}
>
<Highlighter size={16} /> <span className="compact-btn-label">{t('albumDetail.artistBio')}</span>
</button>
)}
{policy.canDownload && (
downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button
className="btn btn-surface"
id="album-download-btn"
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> <span className="compact-btn-label">{t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}</span>
</button>
)
)}
{policy.canPinOffline && (
offlineStatus === 'downloading' && offlineProgress ? (
<div className="offline-cache-btn offline-cache-btn--progress">
<Loader2 size={14} className="spin" />
{t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })}
</div>
) : offlineStatus === 'queued' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
<span className="compact-btn-label">{t('albumDetail.offlineQueued')}</span>
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
<span className="compact-btn-label">{t('albumDetail.offlineCached')}</span>
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
<span className="compact-btn-label">{t('albumDetail.cacheOffline')}</span>
</button>
)
)}
</div>
)}
</div>
</div>
</div>
</div>
</>
);
}
+296
View File
@@ -0,0 +1,296 @@
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import React, { useRef, useState, useEffect, useLayoutEffect, useMemo } from 'react';
import AlbumCard from '@/features/album/components/AlbumCard';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { NavLink, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { dedupeById } from '@/utils/dedupeById';
interface Props {
title: string;
titleLink?: string;
albums: SubsonicAlbum[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
/** Restored horizontal scroll (e.g. Advanced Search session return). */
restoreScrollLeft?: number;
/** Parent stashes horizontal scroll when leaving the page. */
onScrollLeftSnapshot?: (scrollLeft: number) => void;
/** Fired once when `restoreScrollLeft` has been applied (or skipped). */
onScrollRestoreComplete?: () => void;
showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
disableArtwork?: boolean;
disableInteractivity?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
/** Appended to `/album/:id` links, e.g. `lossless=1`. */
albumLinkQuery?: string;
/** Search/browse rows: API `coverArt` only — no per-card library_resolve IPC. */
libraryResolve?: boolean;
}
export default function AlbumRow({
title,
titleLink,
albums,
moreLink,
moreText,
onLoadMore,
showRating,
headerExtra,
disableArtwork = false,
disableInteractivity = false,
artworkSize,
windowArtworkByViewport = false,
initialArtworkBudget = 8,
albumLinkQuery,
libraryResolve = false,
restoreScrollLeft,
onScrollLeftSnapshot,
onScrollRestoreComplete,
}: Props) {
const perfFlags = usePerfProbeFlags();
const artworkDisabled = perfFlags.disableMainstageRailArtwork || disableArtwork;
const interactivityDisabled = perfFlags.disableMainstageRailInteractivity || disableInteractivity;
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [artworkBudget, setArtworkBudget] = useState(initialArtworkBudget);
const loadingRef = useRef(false);
const scrollRestoreTargetRef = useRef(restoreScrollLeft);
const scrollRestoreDoneRef = useRef(false);
const uniqueAlbums = useMemo(() => dedupeById(albums), [albums]);
const recomputeArtworkBudget = () => {
if (!windowArtworkByViewport) return;
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, clientWidth } = el;
const firstCard = el.querySelector<HTMLElement>('.album-card, .artist-card');
const cardW = firstCard?.clientWidth || firstCard?.getBoundingClientRect().width || 170;
const gridStyles = window.getComputedStyle(el);
const gap = Number.parseFloat(gridStyles.columnGap || gridStyles.gap || '16') || 16;
const step = Math.max(1, cardW + gap);
const visibleCount = Math.ceil((scrollLeft + clientWidth) / step);
// Extra slack so fast horizontal scroll doesnt hit the idx≥budget cliff between frames.
const nextBudget = Math.max(initialArtworkBudget, visibleCount + 12);
setArtworkBudget(prev => (nextBudget > prev ? nextBudget : prev));
};
const handleScroll = () => {
if (windowArtworkByViewport) recomputeArtworkBudget();
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
if (!interactivityDisabled) {
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
}
onScrollLeftSnapshot?.(scrollLeft);
// Auto-load trigger (native horizontal scroll still works when rail buttons are perf-disabled)
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
triggerLoadMore();
}
};
const triggerLoadMore = async () => {
if (!onLoadMore || loadingRef.current) return;
loadingRef.current = true;
setLoadingMore(true);
await onLoadMore();
setLoadingMore(false);
loadingRef.current = false;
};
useEffect(() => {
handleScroll();
const raf = window.requestAnimationFrame(() => {
if (windowArtworkByViewport) recomputeArtworkBudget();
});
window.addEventListener('resize', handleScroll);
const ro = new ResizeObserver(() => {
if (windowArtworkByViewport) recomputeArtworkBudget();
});
if (scrollRef.current) ro.observe(scrollRef.current);
return () => {
window.cancelAnimationFrame(raf);
window.removeEventListener('resize', handleScroll);
ro.disconnect();
};
// handleScroll/recomputeArtworkBudget are recreated each render but read live
// refs/props; the listeners are intentionally (re)bound only when the row data
// or artwork config changes, not on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [uniqueAlbums, interactivityDisabled, windowArtworkByViewport, initialArtworkBudget]);
// Reset when the rows identity changes (new data / server), not when the list grows via
// “load more” — reusing albums.length would shrink the budget mid-scroll and flash placeholders.
const rowArtworkResetKey = uniqueAlbums[0]?.id ?? '';
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setArtworkBudget(initialArtworkBudget);
}, [initialArtworkBudget, rowArtworkResetKey]);
const notifyRestoreCompletePendingRef = useRef(false);
const [restoreCompleteTick, setRestoreCompleteTick] = useState(0);
useEffect(() => {
if (restoreScrollLeft == null || restoreScrollLeft <= 0) return;
scrollRestoreTargetRef.current = restoreScrollLeft;
scrollRestoreDoneRef.current = false;
notifyRestoreCompletePendingRef.current = false;
}, [restoreScrollLeft]);
useLayoutEffect(() => {
if (scrollRestoreDoneRef.current) return;
const target = scrollRestoreTargetRef.current;
if (target == null || target <= 0) {
scrollRestoreDoneRef.current = true;
onScrollRestoreComplete?.();
return;
}
let attempts = 0;
let cancelled = false;
const finish = () => {
scrollRestoreDoneRef.current = true;
if (windowArtworkByViewport) {
notifyRestoreCompletePendingRef.current = true;
setRestoreCompleteTick(t => t + 1);
return;
}
onScrollRestoreComplete?.();
};
const attempt = () => {
if (cancelled || scrollRestoreDoneRef.current) return;
const el = scrollRef.current;
if (!el) {
if (++attempts < 12) requestAnimationFrame(attempt);
else finish();
return;
}
const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth);
const desired = Math.min(Math.max(0, target), maxScroll);
el.scrollLeft = desired;
if (windowArtworkByViewport) recomputeArtworkBudget();
handleScroll();
const stuck = Math.abs(el.scrollLeft - desired) <= 1;
const layoutStillGrowing = desired > el.scrollLeft + 1 && maxScroll < target;
if ((!stuck || layoutStillGrowing) && ++attempts < 12) {
requestAnimationFrame(attempt);
return;
}
finish();
};
attempt();
return () => {
cancelled = true;
};
// handleScroll/recomputeArtworkBudget/onScrollRestoreComplete are recreated
// each render but read live state; the restore pass is intentionally keyed on
// the row identity / layout signals, not on those callback identities.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rowArtworkResetKey, windowArtworkByViewport, initialArtworkBudget, uniqueAlbums.length]);
useLayoutEffect(() => {
if (!notifyRestoreCompletePendingRef.current) return;
notifyRestoreCompletePendingRef.current = false;
onScrollRestoreComplete?.();
}, [artworkBudget, restoreCompleteTick, onScrollRestoreComplete]);
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
const amount = scrollRef.current.clientWidth * 0.75;
scrollRef.current.scrollBy({ left: dir === 'left' ? -amount : amount, behavior: 'smooth' });
};
if (uniqueAlbums.length === 0) return null;
return (
<section className="album-row-section">
<div className="album-row-header">
{titleLink ? (
<NavLink to={titleLink} className="section-title-link" style={{ marginBottom: 0 }}>
{title}<ChevronRight size={18} className="section-title-chevron" />
</NavLink>
) : (
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)}
<div className="album-row-nav">
{headerExtra}
{!interactivityDisabled && (
<>
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<ChevronRight size={20} />
</button>
</>
)}
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{uniqueAlbums.map((a, idx) => (
<AlbumCard
key={a.serverId ? `${a.serverId}:${a.id}` : a.id}
album={a}
showRating={showRating}
linkQuery={albumLinkQuery}
libraryResolve={libraryResolve}
disableArtwork={
artworkDisabled ||
(windowArtworkByViewport && idx >= artworkBudget)
}
artworkSize={artworkSize}
/>
))}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: 'var(--radius-sm)' }}>
<ArrowRight size={24} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{moreText}</span>
</div>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,209 @@
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import React, { useMemo, useState, useEffect } from 'react';
import { useTracklistColumns } from '@/utils/useTracklistColumns';
import { usePlayerStore } from '@/store/playerStore';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useSelectionStore } from '@/store/selectionStore';
import {
COLUMNS,
type SortKey,
} from '@/features/album/utils/albumTrackListHelpers';
import { useAlbumTrackListSelection } from '@/features/album/hooks/useAlbumTrackListSelection';
import { TrackRow } from '@/features/album/components/TrackRow';
import { AlbumTrackListMobile } from '@/features/album/components/AlbumTrackListMobile';
import { TracklistColumnPicker } from '@/components/albumTrackList/TracklistColumnPicker';
import { TracklistHeaderRow } from '@/features/album/components/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
export type { SortKey } from '@/features/album/utils/albumTrackListHelpers';
interface AlbumTrackListProps {
songs: SubsonicSong[];
/** Per-disc subtitles from the album payload, rendered after "CD N". */
discTitles?: { disc: number; title: string }[];
sorted?: boolean;
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
/** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
actionPolicy?: OfflineActionPolicy;
}
// ── AlbumTrackList ────────────────────────────────────────────────────────────
export default function AlbumTrackList({
songs,
discTitles,
sorted,
hasVariousArtists: _hasVariousArtists,
currentTrack,
isPlaying,
ratings,
userRatingOverrides,
starredSongs,
onPlaySong,
onDoubleClickSong,
onRate,
onToggleSongStar,
onContextMenu,
sortKey,
sortDir,
onSort,
actionPolicy,
}: AlbumTrackListProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn, resetColumns,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const {
inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll,
} = useAlbumTrackListSelection({ songs, tracklistRef });
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!contextMenuOpen) setContextMenuSongId(null);
}, [contextMenuOpen]);
// ── Disc grouping ─────────────────────────────────────────────────────────
const discs = new Map<number, SubsonicSong[]>();
if (!sorted) {
songs.forEach(song => {
const disc = song.discNumber ?? 1;
if (!discs.has(disc)) discs.set(disc, []);
discs.get(disc)!.push(song);
});
} else {
discs.set(1, songs as SubsonicSong[]);
}
const discNums = sorted ? [1] : Array.from(discs.keys()).sort((a, b) => a - b);
const isMultiDisc = !sorted && discNums.length > 1;
const discTitleByNum = new Map<number, string>(
(discTitles ?? []).filter(d => d.title?.trim()).map(d => [d.disc, d.title.trim()]),
);
const currentTrackId = currentTrack?.id ?? null;
const displayCols = useMemo(
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
[policy.canFavorite, visibleCols],
);
if (isMobile) {
return (
<AlbumTrackListMobile
discNums={discNums}
discs={discs}
discTitleByNum={discTitleByNum}
isMultiDisc={isMultiDisc}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
onPlaySong={onPlaySong}
onContextMenu={onContextMenu}
/>
);
}
return (
<>
<TracklistColumnPicker
allColumns={COLUMNS}
pickerRef={pickerRef}
pickerOpen={pickerOpen}
setPickerOpen={setPickerOpen}
colVisible={colVisible}
toggleColumn={toggleColumn}
resetColumns={resetColumns}
t={t}
/>
<div
className="tracklist"
ref={tracklistRef}
data-preview-loc="albums"
onClick={e => {
if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll();
}}
>
<TracklistHeaderRow
visibleCols={displayCols}
gridStyle={gridStyle}
sortKey={sortKey}
sortDir={sortDir}
onSort={onSort}
allSelected={allSelected}
inSelectMode={inSelectMode}
toggleAll={toggleAll}
startResize={startResize}
t={t}
/>
{/* ── Tracks ── */}
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span>
CD {discNum}
{discTitleByNum.get(discNum) && (
<span className="disc-subtitle">{discTitleByNum.get(discNum)}</span>
)}
</div>
)}
{discs.get(discNum)!.map(song => {
const globalIdx = songs.indexOf(song);
return (
<TrackRow
key={song.id}
song={song}
globalIdx={globalIdx}
visibleCols={displayCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
isPlaying={isPlaying}
ratingValue={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
isStarred={starredSongs.has(song.id)}
inSelectMode={inSelectMode}
isContextMenuSong={contextMenuSongId === song.id}
onPlaySong={onPlaySong}
onDoubleClickSong={onDoubleClickSong}
onRate={onRate}
onToggleSongStar={onToggleSongStar}
onContextMenu={onContextMenu}
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
actionPolicy={policy}
/>
);
})}
</div>
))}
</div>
</>
);
}
@@ -0,0 +1,87 @@
import React from 'react';
import { AudioLines } from 'lucide-react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { formatLongDuration } from '@/utils/format/formatDuration';
interface Props {
discNums: number[];
discs: Map<number, SubsonicSong[]>;
discTitleByNum: Map<number, string>;
isMultiDisc: boolean;
currentTrackId: string | null;
isPlaying: boolean;
contextMenuSongId: string | null;
setContextMenuSongId: (id: string | null) => void;
onPlaySong: (song: SubsonicSong) => void;
onContextMenu: (
x: number,
y: number,
track: Track,
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song',
) => void;
}
/**
* Compact tracklist for narrow viewports. Drops the column grid + column
* picker + drag selection entirely — just a one-line row per song with
* disc group separators. Play on tap, context menu on long-press / right
* click.
*/
export function AlbumTrackListMobile({
discNums,
discs,
discTitleByNum,
isMultiDisc,
currentTrackId,
isPlaying,
contextMenuSongId,
setContextMenuSongId,
onPlaySong,
onContextMenu,
}: Props) {
return (
<div className="tracklist-mobile">
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span> CD {discNum}
{discTitleByNum.get(discNum) && (
<span className="disc-subtitle">{discTitleByNum.get(discNum)}</span>
)}
</div>
)}
{discs.get(discNum)!.map(song => {
const isActive = currentTrackId === song.id;
return (
<div
key={song.id}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="tracklist-mobile-main">
{isActive && isPlaying ? (
<span className="tracklist-mobile-eq">
<AudioLines className="eq-bars" size={14} />
</span>
) : (
<span className="tracklist-mobile-num">{song.track ?? ''}</span>
)}
<span className="tracklist-mobile-title">{song.title}</span>
</div>
<span className="tracklist-mobile-duration">{formatLongDuration(song.duration)}</span>
</div>
);
})}
</div>
))}
</div>
);
}
@@ -0,0 +1,67 @@
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ndListLosslessAlbumsPage } from '@/api/navidromeBrowse';
import AlbumRow from '@/features/album/components/AlbumRow';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { runLocalLosslessAlbums } from '@/utils/library/browseTextSearch';
import { LOSSLESS_MODE_QUERY } from '@/utils/library/losslessMode';
interface Props {
disableArtwork?: boolean;
artworkSize?: number;
windowArtworkByViewport?: boolean;
initialArtworkBudget?: number;
}
const TARGET_ALBUMS = 20;
export default function LosslessAlbumsRail({
disableArtwork = false,
artworkSize,
windowArtworkByViewport,
initialArtworkBudget,
}: Props) {
const { t } = useTranslation();
const activeServerId = useAuthStore(s => s.activeServerId);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId ?? ''));
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
if (indexEnabled && activeServerId) {
const local = await runLocalLosslessAlbums(activeServerId, TARGET_ALBUMS, 0);
if (cancelled) return;
if (local && local.albums.length > 0) {
setAlbums(local.albums);
return;
}
}
try {
const page = await ndListLosslessAlbumsPage({ targetNewAlbums: TARGET_ALBUMS });
if (cancelled) return;
setAlbums(page.entries.map(e => e.album));
} catch {
if (!cancelled) setAlbums([]);
}
})();
return () => { cancelled = true; };
}, [activeServerId, indexEnabled]);
if (albums.length === 0) return null;
return (
<AlbumRow
title={t('home.losslessAlbums')}
titleLink="/lossless-albums"
albums={albums}
disableArtwork={disableArtwork}
artworkSize={artworkSize}
windowArtworkByViewport={windowArtworkByViewport}
initialArtworkBudget={initialArtworkBudget}
albumLinkQuery={LOSSLESS_MODE_QUERY}
/>
);
}
+270
View File
@@ -0,0 +1,270 @@
import React from 'react';
import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { ColDef } from '@/utils/useTracklistColumns';
import type { SubsonicSong } from '@/api/subsonicTypes';
import type { Track } from '@/store/playerStoreTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useSelectionStore } from '@/store/selectionStore';
import { useThemeStore } from '@/store/themeStore';
import { previewInputFromSong, usePreviewStore } from '@/store/previewStore';
import StarRating from '@/components/StarRating';
import { codecLabel, type ColKey } from '@/features/album/utils/albumTrackListHelpers';
import { formatLongDuration } from '@/utils/format/formatDuration';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/i18n';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { resolveTrackArtistRefs } from '@/utils/playback/trackArtistRefs';
type ContextMenuFn = (
x: number,
y: number,
track: Track,
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song',
) => void;
interface TrackRowProps {
song: SubsonicSong;
globalIdx: number;
visibleCols: readonly ColDef[];
gridStyle: React.CSSProperties;
currentTrackId: string | null;
isPlaying: boolean;
ratingValue: number;
isStarred: boolean;
inSelectMode: boolean;
isContextMenuSong: boolean;
onPlaySong: (song: SubsonicSong) => void;
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: ContextMenuFn;
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
setContextMenuSongId: (id: string | null) => void;
actionPolicy?: OfflineActionPolicy;
}
/**
* Memoised tracklist row. Subscribes to its own selection + preview state
* via primitive selectors so only this row re-renders when the user
* toggles selection or starts/stops a preview.
*/
export const TrackRow = React.memo(function TrackRow({
song,
globalIdx,
visibleCols,
gridStyle,
currentTrackId,
isPlaying,
ratingValue,
isStarred,
inSelectMode,
isContextMenuSong,
onPlaySong,
onDoubleClickSong,
onRate,
onToggleSongStar,
onContextMenu,
onToggleSelect,
onDragStart,
setContextMenuSongId,
actionPolicy,
}: TrackRowProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const navigate = useNavigate();
const showBitrate = useThemeStore(s => s.showBitrate);
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
const isActive = currentTrackId === song.id;
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
const renderCell = (colDef: ColDef) => {
const key = colDef.key as ColKey;
switch (key) {
case 'num':
return (
<div
key="num"
className={`track-num${isActive ? ' track-num-active' : ''}`}
>
<span
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
/>
{isActive && isPlaying ? (
<span className="track-num-eq">
<AudioLines className="eq-bars" size={14} />
</span>
) : (
<span className="track-num-number">{song.track ?? '—'}</span>
)}
</div>
);
case 'title':
return (
<div key="title" className="track-info track-info-suggestion">
<button
type="button"
className="playlist-suggestion-play-btn"
onClick={e => { e.stopPropagation(); onPlaySong(song); }}
onDoubleClick={onDoubleClickSong ? e => { e.stopPropagation(); onDoubleClickSong(song); } : undefined}
data-tooltip={t('common.play')}
aria-label={t('common.play')}
>
<Play size={10} fill="currentColor" strokeWidth={0} className="playlist-suggestion-play-icon" />
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums');
}}
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
>
<svg className="playlist-suggestion-preview-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-track" />
<circle cx="12" cy="12" r="10.5" className="playlist-suggestion-preview-ring-progress" />
</svg>
{isPreviewing
? <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>
<span className="track-title">{song.title}</span>
</div>
);
case 'artist': {
const artistRefs = resolveTrackArtistRefs(song);
return (
<div key="artist" className="track-artist-cell">
{artistRefs.map((a, i) => (
<React.Fragment key={a.id ?? a.name ?? i}>
{i > 0 && <span className="track-artist-sep">&nbsp;·&nbsp;</span>}
<span
className={`track-artist${a.id ? ' track-artist-link' : ''}`}
style={{ cursor: a.id ? 'pointer' : 'default' }}
onClick={e => { if (a.id) { e.stopPropagation(); navigate(`/artist/${a.id}`); } }}
>
{a.name ?? song.artist}
</span>
</React.Fragment>
))}
</div>
);
}
case 'favorite':
return (
<div key="favorite" className="track-star-cell">
<button
className={`btn btn-ghost track-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating':
return (
<StarRating
key="rating"
value={ratingValue}
onChange={r => onRate(song.id, r)}
disabled={!policy.canRate}
/>
);
case 'duration':
return (
<div key="duration" className="track-duration">
{formatLongDuration(song.duration)}
</div>
);
case 'format':
return (
<div key="format" className="track-meta">
{(song.suffix || (showBitrate && song.bitRate)) && (
<span className="track-codec">{codecLabel(song, showBitrate)}</span>
)}
</div>
);
case 'genre':
return (
<div key="genre" className="track-genre">
{song.genre ?? '—'}
</div>
);
case 'playCount':
return (
<div key="playCount" className="track-duration">
{song.playCount ?? '—'}
</div>
);
case 'lastPlayed':
return (
<div key="lastPlayed" className="track-genre">
{song.played ? formatLastSeen(song.played, i18n.language, '—') : '—'}
</div>
);
case 'bpm':
return (
<div key="bpm" className="track-duration">
{song.bpm && song.bpm > 0 ? song.bpm : '—'}
</div>
);
default:
return null;
}
};
return (
<div
className={`track-row track-row-va track-row-with-actions${isActive ? ' active' : ''}${isContextMenuSong ? ' context-active' : ''}${isSelected ? ' bulk-selected' : ''}`}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
onToggleSelect(song.id, globalIdx, false);
} else if (inSelectMode) {
onToggleSelect(song.id, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
}}
onDoubleClick={onDoubleClickSong ? e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey || inSelectMode) return;
onDoubleClickSong(song);
} : undefined}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
onDragStart(song, me);
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
{visibleCols.map(colDef => renderCell(colDef))}
</div>
);
});
@@ -0,0 +1,142 @@
import React from 'react';
import type { TFunction } from 'i18next';
import type { ColDef } from '@/utils/useTracklistColumns';
import { CENTERED_COLS, isSortable, type ColKey, type SortKey } from '@/features/album/utils/albumTrackListHelpers';
interface Props {
visibleCols: readonly ColDef[];
gridStyle: React.CSSProperties;
sortKey?: SortKey;
sortDir?: 'asc' | 'desc';
onSort?: (key: SortKey) => void;
allSelected: boolean;
inSelectMode: boolean;
toggleAll: () => void;
startResize: (e: React.MouseEvent, colIndex: number, direction: 1 | -1) => void;
t: TFunction;
}
/**
* The fixed tracklist header row. Each cell is independently sortable
* (when the column is in `SORTABLE_COLS` and `onSort` is provided) and
* resizable via a 6px drop-target along its right edge.
*
* The `num` cell additionally hosts the bulk-selection toggle so that
* shift-click-style ranges anchor against the header.
*/
export function TracklistHeaderRow({
visibleCols,
gridStyle,
sortKey,
sortDir,
onSort,
allSelected,
inSelectMode,
toggleAll,
startResize,
t,
}: Props) {
const handleHeaderClick = (key: ColKey | string) => {
if (!isSortable(key) || !onSort) return;
onSort(key);
};
const renderSortIndicator = (key: SortKey) => {
if (sortKey !== key) return null;
return (
<span style={{ marginLeft: 4, fontSize: 10, opacity: 0.7 }}>
{sortDir === 'asc' ? '▲' : '▼'}
</span>
);
};
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
const canSort = isSortable(key) && onSort;
const isActive = canSort && sortKey === key;
if (key === 'num') {
return (
<div key={key} className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
}
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div
key={key}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleHeaderClick(key)}
className={isActive ? 'tracklist-header-cell-active' : ''}
>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isActive ? 600 : 400 }}>{label}</span>
{canSort && renderSortIndicator(key as SortKey)}
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
const isResizable = !isLastCol;
return (
<div
key={key}
style={{
position: 'relative',
padding: 0,
margin: 0,
minWidth: 0,
overflow: 'hidden',
cursor: canSort ? 'pointer' : 'default',
userSelect: 'none',
}}
onClick={() => handleHeaderClick(key)}
className={isActive ? 'tracklist-header-cell-active' : ''}
>
<div
style={{
display: 'flex', width: '100%', height: '100%', alignItems: 'center',
justifyContent: isCentered ? 'center' : 'flex-start',
paddingLeft: isCentered ? 0 : 12,
}}
>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontWeight: isActive ? 600 : 400 }}>{label}</span>
{canSort && isSortable(key) && renderSortIndicator(key as SortKey)}
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
};
return (
<div className="tracklist-header-wrapper">
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
// Keep pagination termination rules aligned with the hook implementation.
function resolveHasMoreAfterPage(
page: { albums: SubsonicAlbum[]; hasMore: boolean },
append: boolean,
prevCount: number,
mergedCount: number,
): boolean {
if (page.albums.length === 0) return false;
if (append && mergedCount === prevCount) return false;
return page.hasMore;
}
describe('resolveHasMoreAfterPage', () => {
it('stops when the server returns an empty page', () => {
expect(resolveHasMoreAfterPage({ albums: [], hasMore: true }, true, 30, 30)).toBe(false);
});
it('stops when dedupe adds no new albums', () => {
expect(resolveHasMoreAfterPage({ albums: [{ id: 'a1' } as SubsonicAlbum], hasMore: true }, true, 1, 1)).toBe(false);
});
it('continues while the page grows and the server reports more', () => {
expect(resolveHasMoreAfterPage({ albums: [{ id: 'a2' } as SubsonicAlbum], hasMore: true }, true, 1, 2)).toBe(true);
});
});
@@ -0,0 +1,529 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import {
coverTrafficBeginGridPagination,
coverTrafficEndGridPagination,
coverTrafficGridPaginationDepth,
} from '@/cover/coverTraffic';
import { coverEnsureQueueBacklog, coverEnsureResumePump, coverEnsureSubscribeBacklogDrain } from '@/cover/ensureQueue';
import { dedupeById } from '@/utils/dedupeById';
import { albumBrowseCompScanComplete, albumBrowseCompFilterClientOnly } from '@/utils/library/albumCompilation';
import type { AlbumCompFilter } from '@/utils/library/albumCompilation';
import {
albumBrowseHasGenreFilter,
albumBrowseHasServerFilters,
applyAlbumBrowseClientFilters,
fetchAlbumBrowseGenreOptions,
fetchAlbumBrowsePage,
fetchLocalAlbumCatalogChunk,
type AlbumBrowseQuery,
type GenreFilterOption,
} from '@/utils/library/albumBrowseLoad';
import { libraryScopeForServer } from '@/api/subsonicClient';
import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
} from '@/utils/library/albumYearFilter';
import { loadOfflineAlbumBrowseInitial } from '@/features/offline';
import { useOfflineBrowseReloadToken } from '@/features/offline';
import {
fetchAlbumBrowseCatalogChunk,
mergeAlbumCatalogChunk,
} from '@/utils/library/albumBrowseCatalogChunk';
import { useOfflineBrowseContext } from '@/features/offline';
import { useClientSliceInfiniteScroll } from '@/hooks/useClientSliceInfiniteScroll';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useInpageScrollSentinel } from '@/hooks/useInpageScrollSentinel';
const PAGE_SIZE = 30;
const CLIENT_SLICE_PAGE_SIZE = 60;
/** Local-index catalog buffer grows by this many albums per background SQL chunk. */
const CATALOG_CHUNK_SIZE = 200;
/** Wait for visible-row cover ensures to drain before fetching the next SQL page (network mode). */
const LOAD_MORE_COVER_BACKLOG_MAX = 12;
type AlbumBrowseMode = 'slice' | 'page';
export type UseAlbumBrowseDataArgs = {
serverId: string;
indexEnabled: boolean;
musicLibraryFilterVersion: number;
sort: AlbumBrowseQuery['sort'];
selectedGenres: string[];
yearFrom: string;
yearTo: string;
losslessOnly: boolean;
starredOnly: boolean;
compFilter: AlbumCompFilter;
starredOverrides: Record<string, boolean>;
/** IntersectionObserver scroll root (Albums in-page viewport). */
getScrollRoot?: () => HTMLElement | null;
/** Bumps when the scroll root mounts so the sentinel observer can rebind. */
scrollRootEl?: HTMLElement | null;
/** Bootstrap visible slice size when restoring scroll after album-detail back. */
restoreDisplayCount?: number;
};
function resolveHasMoreAfterPage(
page: { albums: SubsonicAlbum[]; hasMore: boolean },
append: boolean,
prevCount: number,
mergedCount: number,
): boolean {
if (page.albums.length === 0) return false;
if (append && mergedCount === prevCount) return false;
return page.hasMore;
}
export function useAlbumBrowseData({
serverId,
indexEnabled,
musicLibraryFilterVersion,
sort,
selectedGenres,
yearFrom,
yearTo,
losslessOnly,
starredOnly,
compFilter,
starredOverrides,
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
}: UseAlbumBrowseDataArgs) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [browseMode, setBrowseMode] = useState<AlbumBrowseMode>('page');
const [genreCatalogOptions, setGenreCatalogOptions] = useState<GenreFilterOption[] | null>(null);
const yearFields = useMemo(() => ({ from: yearFrom, to: yearTo }), [yearFrom, yearTo]);
const debouncedYearFields = useDebouncedValue(yearFields, ALBUM_YEAR_FILTER_DEBOUNCE_MS);
const { active: yearFilterActive, bounds: yearFilterBounds } = useMemo(
() => resolveAlbumYearBounds(debouncedYearFields.from, debouncedYearFields.to),
[debouncedYearFields.from, debouncedYearFields.to],
);
const browseQuery = useMemo<AlbumBrowseQuery>(() => ({
sort,
genres: selectedGenres,
year: yearFilterActive ? yearFilterBounds : undefined,
losslessOnly,
starredOnly,
compFilter,
}), [sort, selectedGenres, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]);
const browseQueryWithoutGenre = useMemo<AlbumBrowseQuery>(() => ({
sort,
genres: [],
year: yearFilterActive ? yearFilterBounds : undefined,
losslessOnly,
starredOnly,
compFilter,
}), [sort, yearFilterActive, yearFilterBounds, losslessOnly, starredOnly, compFilter]);
const compFilterActive = compFilter !== 'all';
const compFilterClientOnly = albumBrowseCompFilterClientOnly(compFilter, browseMode);
const visibleAlbums = useMemo(
() => applyAlbumBrowseClientFilters(albums, browseQuery, starredOverrides, browseMode),
[albums, browseQuery, starredOverrides, browseMode],
);
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
} = useClientSliceInfiniteScroll({
pageSize: CLIENT_SLICE_PAGE_SIZE,
resetDeps: [
browseMode,
sort,
selectedGenres,
yearFilterActive,
yearFilterBounds,
losslessOnly,
starredOnly,
compFilter,
musicLibraryFilterVersion,
serverId,
],
getScrollRoot,
scrollRootEl,
restoreDisplayCount,
});
const displayAlbums = useMemo(() => {
if (browseMode !== 'slice') return visibleAlbums;
return visibleAlbums.slice(0, visibleCount);
}, [browseMode, visibleAlbums, visibleCount]);
const genreFiltered = albumBrowseHasGenreFilter(browseQuery);
const serverFilterActive = albumBrowseHasServerFilters(browseQuery);
const libraryScopeActive = libraryScopeForServer(serverId) != null;
const narrowGenreList = yearFilterActive || losslessOnly || starredOnly || compFilterActive;
/** When true, GenreFilterBar uses `genreCatalogOptions` instead of server `getGenres()`. */
const genreCatalogActive = narrowGenreList || (indexEnabled && libraryScopeActive);
const compScanExhausted = useMemo(
() => compFilterClientOnly && !genreFiltered
&& albumBrowseCompScanComplete(albums, compFilter, hasMore),
[compFilterClientOnly, genreFiltered, albums, compFilter, hasMore],
);
const pendingClientFilterMatch =
compFilterClientOnly && visibleAlbums.length === 0 && hasMore && !genreFiltered && !compScanExhausted;
const gridHasMore = browseMode === 'slice'
? visibleCount < visibleAlbums.length || catalogHasMore
: hasMore && !genreFiltered;
const gridLoadingMore = browseMode === 'slice'
? sliceLoadingMore || catalogLoadingMore
: loadingMore;
const loadGenerationRef = useRef(0);
const pageRef = useRef(0);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
const loadMoreRef = useRef<() => void>(() => {});
const sentinelIntersectingRef = useRef(false);
const browseModeRef = useRef(browseMode);
// 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
browseModeRef.current = browseMode;
useEffect(() => {
while (coverTrafficGridPaginationDepth() > 0) {
coverTrafficEndGridPagination();
}
coverEnsureResumePump();
}, []);
useEffect(() => {
return coverEnsureSubscribeBacklogDrain(() => {
if (browseModeRef.current !== 'page') return;
if (!sentinelIntersectingRef.current) return;
if (loadingRef.current || loadPendingRef.current) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
loadMoreRef.current();
});
}, []);
useEffect(() => {
pageRef.current = page;
}, [page]);
const loadCatalogChunk = useCallback(async (
query: AlbumBrowseQuery,
offset: number,
append: boolean,
) => {
if (catalogLoadingRef.current) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchAlbumBrowseCatalogChunk(
serverId,
indexEnabled,
query,
offset,
CATALOG_CHUNK_SIZE,
starredOverrides,
);
if (generation !== loadGenerationRef.current || chunk == null) return;
setAlbums(prev => {
const { albums: next, offset: nextOffset } = mergeAlbumCatalogChunk(prev, chunk, append);
catalogOffsetRef.current = nextOffset;
return next;
});
setCatalogHasMore(chunk.hasMore);
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
// offlineBrowseActive is an intentional re-create trigger so the catalog
// reloads from the right source when offline browse toggles; the loader reads
// the active mode internally rather than referencing the flag directly here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexEnabled, offlineBrowseActive, serverId, starredOverrides]);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
offset: number,
append = false,
) => {
const generation = ++loadGenerationRef.current;
loadingRef.current = true;
loadPendingRef.current = true;
coverTrafficBeginGridPagination();
if (append) setLoadingMore(true);
else setLoading(true);
const applyPage = (pageResult: { albums: SubsonicAlbum[]; hasMore: boolean }) => {
if (generation !== loadGenerationRef.current) return;
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...pageResult.albums]);
setHasMore(resolveHasMoreAfterPage(pageResult, true, prev.length, merged.length));
return merged;
});
} else {
setAlbums(pageResult.albums);
setHasMore(resolveHasMoreAfterPage(pageResult, false, 0, pageResult.albums.length));
}
};
try {
const pageResult = await fetchAlbumBrowsePage(
serverId,
indexEnabled,
query,
offset,
PAGE_SIZE,
{
onPartial: partial => {
if (generation !== loadGenerationRef.current) return;
applyPage(partial);
loadingRef.current = false;
if (append) setLoadingMore(false);
else setLoading(false);
},
},
);
applyPage(pageResult);
} finally {
coverTrafficEndGridPagination();
coverEnsureResumePump();
if (generation === loadGenerationRef.current) {
loadingRef.current = false;
loadPendingRef.current = false;
if (append) setLoadingMore(false);
else setLoading(false);
}
}
}, [indexEnabled, serverId]);
useEffect(() => {
let cancelled = false;
pageRef.current = 0;
catalogOffsetRef.current = 0;
loadPendingRef.current = false;
catalogLoadingRef.current = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPage(0);
setAlbums([]);
setHasMore(true);
setCatalogHasMore(false);
setCatalogLoadingMore(false);
setLoading(true);
void (async () => {
if (offlineBrowseActive) {
const generation = ++loadGenerationRef.current;
if (cancelled || generation !== loadGenerationRef.current) return;
setBrowseMode('slice');
try {
const first = await loadOfflineAlbumBrowseInitial(
serverId,
browseQuery,
CATALOG_CHUNK_SIZE,
starredOverrides,
);
if (cancelled || generation !== loadGenerationRef.current) return;
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
setCatalogHasMore(first.hasMore);
} catch {
setAlbums([]);
setCatalogHasMore(false);
}
setLoading(false);
return;
}
if (indexEnabled && serverId) {
const generation = ++loadGenerationRef.current;
coverTrafficBeginGridPagination();
try {
const first = await fetchLocalAlbumCatalogChunk(
serverId,
indexEnabled,
browseQuery,
0,
CATALOG_CHUNK_SIZE,
);
if (cancelled || generation !== loadGenerationRef.current) return;
if (first != null) {
setBrowseMode('slice');
setAlbums(first.albums);
catalogOffsetRef.current = first.albums.length;
setCatalogHasMore(first.hasMore);
setLoading(false);
return;
}
} finally {
coverTrafficEndGridPagination();
coverEnsureResumePump();
}
}
if (cancelled) return;
setBrowseMode('page');
await loadBrowse(browseQuery, 0, false);
})();
return () => {
cancelled = true;
};
// starredOverrides is read to seed star state during the load, but the browse
// list must not reload on every star toggle — it is intentionally excluded.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]);
useEffect(() => {
if (!genreCatalogActive) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setGenreCatalogOptions(null);
return;
}
let cancelled = false;
void fetchAlbumBrowseGenreOptions(serverId, indexEnabled, browseQueryWithoutGenre).then(options => {
if (!cancelled) setGenreCatalogOptions(options);
});
return () => {
cancelled = true;
};
}, [
genreCatalogActive,
serverId,
indexEnabled,
browseQueryWithoutGenre,
musicLibraryFilterVersion,
]);
const loadMorePage = useCallback(() => {
if (loadingRef.current || loadPendingRef.current || !hasMore || genreFiltered) return;
if (coverEnsureQueueBacklog() > LOAD_MORE_COVER_BACKLOG_MAX) return;
if (compFilterClientOnly && visibleAlbums.length === 0
&& albumBrowseCompScanComplete(albums, compFilter, hasMore)) {
return;
}
const next = pageRef.current + 1;
pageRef.current = next;
setPage(next);
void loadBrowse(browseQuery, next * PAGE_SIZE, true);
}, [
hasMore,
browseQuery,
loadBrowse,
genreFiltered,
compFilterClientOnly,
visibleAlbums.length,
albums,
compFilter,
]);
const loadMoreGrid = useCallback(() => {
if (visibleCount < visibleAlbums.length) {
sliceLoadMore();
return;
}
if (catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(browseQuery, catalogOffsetRef.current, true);
}
}, [
visibleCount,
visibleAlbums.length,
catalogHasMore,
sliceLoadMore,
loadCatalogChunk,
browseQuery,
]);
const loadMore = useCallback(() => {
if (browseMode === 'slice') {
loadMoreGrid();
return;
}
loadMorePage();
}, [browseMode, loadMoreGrid, loadMorePage]);
// 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
loadMoreRef.current = loadMore;
useEffect(() => {
if (browseMode !== 'page') return;
if (!pendingClientFilterMatch || loadingRef.current || loadPendingRef.current) return;
loadMorePage();
}, [browseMode, pendingClientFilterMatch, loading, loadMorePage]);
useEffect(() => {
if (browseMode !== 'slice') return;
if (!sentinelIntersectingRef.current) return;
if (visibleCount >= visibleAlbums.length - CLIENT_SLICE_PAGE_SIZE
&& catalogHasMore
&& !catalogLoadingRef.current) {
void loadCatalogChunk(browseQuery, catalogOffsetRef.current, true);
}
}, [
browseMode,
visibleCount,
visibleAlbums.length,
catalogHasMore,
loadCatalogChunk,
browseQuery,
]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: gridHasMore,
getScrollRoot,
scrollRootEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: gridLoadingMore,
intersectingRef: sentinelIntersectingRef,
});
return {
albums,
loading,
loadingMore: gridLoadingMore,
hasMore: gridHasMore,
displayAlbums,
browseMode,
PAGE_SIZE,
browseQuery,
browseQueryWithoutGenre,
visibleAlbums,
genreFiltered,
serverFilterActive,
narrowGenreList,
genreCatalogActive,
genreCatalogOptions,
yearFilterActive,
debouncedYearFields,
compFilterActive,
compFilterClientOnly,
compScanExhausted,
pendingClientFilterMatch,
loadMore,
bindLoadMoreSentinel,
};
}
@@ -0,0 +1,193 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
ALBUMS_INPAGE_SCROLL_VIEWPORT_ID,
readInpageScrollTop,
} from '@/constants/appScroll';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseCompFilter,
type AlbumBrowseReturnFilters,
type AlbumBrowseSurface,
albumBrowseSortForServer,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '@/features/album/store/albumBrowseSessionStore';
import type { AlbumBrowseSort } from '@/utils/library/browseTextSearch';
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
function returnFiltersForNavigation(
serverId: string,
navigationType: NavigationType,
locationState: unknown,
): AlbumBrowseReturnFilters {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
}
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
export type AlbumBrowseScrollSnapshot = {
scrollTop: number;
displayCount: number;
};
/** Keep scroll snapshot in sync with the in-page viewport (not only on React re-renders). */
export function useAlbumBrowseScrollSnapshotSync(
snapshotRef: RefObject<AlbumBrowseScrollSnapshot>,
scrollBodyEl: HTMLElement | null,
displayCount: number,
): void {
useEffect(() => {
snapshotRef.current.displayCount = displayCount;
}, [displayCount, snapshotRef]);
useEffect(() => {
if (!scrollBodyEl) return;
const syncScrollTop = () => {
snapshotRef.current.scrollTop = scrollBodyEl.scrollTop;
};
syncScrollTop();
scrollBodyEl.addEventListener('scroll', syncScrollTop, { passive: true });
return () => scrollBodyEl.removeEventListener('scroll', syncScrollTop);
}, [scrollBodyEl, snapshotRef]);
}
export function useAlbumBrowseScrollSnapshotRef(
scrollBodyEl: HTMLElement | null,
displayCount: number,
): RefObject<AlbumBrowseScrollSnapshot> {
const snapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
useAlbumBrowseScrollSnapshotSync(snapshotRef, scrollBodyEl, displayCount);
return snapshotRef;
}
export function useAlbumBrowseFilters(
serverId: string,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() =>
returnFiltersForNavigation(serverId, navigationType, location.state).selectedGenres,
);
const [yearFrom, setYearFrom] = useState(() =>
returnFiltersForNavigation(serverId, navigationType, location.state).yearFrom,
);
const [yearTo, setYearTo] = useState(() =>
returnFiltersForNavigation(serverId, navigationType, location.state).yearTo,
);
const [compFilter, setCompFilter] = useState<AlbumBrowseCompFilter>(() =>
returnFiltersForNavigation(serverId, navigationType, location.state).compFilter,
);
const [starredOnly, setStarredOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType, location.state).starredOnly,
);
const [losslessOnly, setLosslessOnly] = useState(() =>
returnFiltersForNavigation(serverId, navigationType, location.state).losslessOnly,
);
const filtersRef = useRef<AlbumBrowseReturnFilters>(DEFAULT_ALBUM_BROWSE_RETURN_FILTERS);
/** Guards against re-reset when `albumBrowseRestore` is cleared from location state. */
const restoredFromStashRef = useRef(false);
// 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
filtersRef.current = {
selectedGenres,
yearFrom,
yearTo,
compFilter,
starredOnly,
losslessOnly,
searchQuery: useLiveSearchScopeStore.getState().query,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, ALBUMS_SURFACE);
if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedGenres(restored.selectedGenres);
setYearFrom(restored.yearFrom);
setYearTo(restored.yearTo);
setCompFilter(restored.compFilter);
setStarredOnly(restored.starredOnly);
setLosslessOnly(restored.losslessOnly);
}
return;
}
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
useLiveSearchScopeStore.getState().setQuery('');
setSelectedGenres([]);
setYearFrom('');
setYearTo('');
setCompFilter('all');
setStarredOnly(false);
setLosslessOnly(false);
}, [serverId, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
// Read at cleanup time on purpose: we want the scroll snapshot as it is
// at navigation-away. Copying it at effect setup would stash a stale value.
// eslint-disable-next-line react-hooks/exhaustive-deps
const snapshot = scrollSnapshotRef?.current;
const scrollTop = Math.max(
readInpageScrollTop(ALBUMS_INPAGE_SCROLL_VIEWPORT_ID),
snapshot?.scrollTop ?? 0,
);
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, ALBUMS_SURFACE, {
...filtersRef.current,
scrollTop,
displayCount: snapshot?.displayCount,
});
} else if (albumBrowseSurfaceForPath(path) !== ALBUMS_SURFACE) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, ALBUMS_SURFACE);
}
};
}, [serverId, scrollSnapshotRef]);
const onSortChange = (value: AlbumBrowseSort) => setBrowseSort(serverId, value);
return {
sort,
onSortChange,
selectedGenres,
setSelectedGenres,
yearFrom,
setYearFrom,
yearTo,
setYearTo,
compFilter,
setCompFilter,
starredOnly,
setStarredOnly,
losslessOnly,
setLosslessOnly,
};
}
@@ -0,0 +1,34 @@
import { useLayoutEffect, useRef, type RefObject } from 'react';
import type { AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
type Args = {
scrollSnapshotRef: RefObject<AlbumBrowseScrollSnapshot>;
getScrollRoot: () => HTMLElement | null;
isScrollRestorePending: boolean;
resetKey: string;
};
/** Scroll to top when browse filters shrink the album grid (e.g. scoped text search). */
export function useAlbumBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey,
}: Args): void {
const prevResetKeyRef = useRef(resetKey);
useLayoutEffect(() => {
if (isScrollRestorePending) return;
if (prevResetKeyRef.current === resetKey) return;
prevResetKeyRef.current = resetKey;
const el = getScrollRoot();
if (!el) return;
if (el.scrollTop !== 0) {
el.scrollTop = 0;
el.dispatchEvent(new Event('scroll', { bubbles: false }));
}
scrollSnapshotRef.current.scrollTop = 0;
}, [resetKey, isScrollRestorePending, getScrollRoot, scrollSnapshotRef]);
}
@@ -0,0 +1,140 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import {
clearGenreDetailReturnStash,
peekAlbumBrowseScrollRestore,
peekGenreDetailScrollRestore,
type AlbumBrowseSurface,
useAlbumBrowseSessionStore,
} from '@/features/album/store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
displayCount: number;
};
export type UseAlbumBrowseScrollRestoreArgs = {
serverId: string;
/** Album grid browse surface (All Albums, New Releases, Random Albums). */
surface?: AlbumBrowseSurface;
/** Genre detail page — uses genre-scoped stash instead of `surface`. */
genreName?: string;
scrollBodyEl: HTMLElement | null;
displayAlbumsLength: number;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseAlbumBrowseScrollRestoreResult = {
/** True until saved scroll position is applied — hide the grid meanwhile. */
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
surface: AlbumBrowseSurface | undefined,
genreName: string | undefined,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null;
if (genreName) return peekGenreDetailScrollRestore(serverId, genreName);
if (surface) return peekAlbumBrowseScrollRestore(serverId, surface);
return null;
}
function clearScrollRestoreStash(
serverId: string,
surface: AlbumBrowseSurface | undefined,
genreName: string | undefined,
): void {
if (genreName) {
clearGenreDetailReturnStash(serverId, genreName);
return;
}
if (surface) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}
}
/**
* When returning to an album grid browse surface via browser/app back from album
* detail, restore the in-page grid scroll position saved in `albumBrowseSessionStore`.
*/
export function useAlbumBrowseScrollRestore({
serverId,
surface,
genreName,
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
}: UseAlbumBrowseScrollRestoreArgs): UseAlbumBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
// React Compiler refs rule: ref used as a once-only init guard (checked before first assignment); not render data.
// eslint-disable-next-line react-hooks/refs
if (!initRef.current) {
initRef.current = true;
// 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
pendingRef.current = readPendingScrollRestore(
serverId,
surface,
genreName,
navigationType,
location.state,
);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, surface, genreName, navigationType, location.state) !== null,
);
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = displayAlbumsLength < pending.displayCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
// React Compiler set-state-in-effect rule: state set from a DOM/layout measurement.
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsScrollRestorePending(false);
clearScrollRestoreStash(serverId, surface, genreName);
}, [
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
surface,
genreName,
]);
return { isScrollRestorePending };
}
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import {
ALBUM_YEAR_MAX,
ALBUM_YEAR_MIN,
type AlbumCatalogYearRange,
} from '@/utils/library/albumYearFilter';
import { fetchAlbumCatalogYearBounds } from '@/utils/library/albumCatalogYearBounds';
const DEFAULT: AlbumCatalogYearRange = { min: ALBUM_YEAR_MIN, max: ALBUM_YEAR_MAX };
export function useAlbumCatalogYearBounds(
serverId: string,
indexEnabled: boolean,
libraryFilterVersion: number,
): AlbumCatalogYearRange {
const [bounds, setBounds] = useState(DEFAULT);
useEffect(() => {
let cancelled = false;
void fetchAlbumCatalogYearBounds(serverId, indexEnabled).then(next => {
if (!cancelled) setBounds(next);
});
return () => {
cancelled = true;
};
}, [serverId, indexEnabled, libraryFilterVersion]);
return bounds;
}
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { useAlbumDetailBack } from '@/features/album/hooks/useAlbumDetailBack';
import { navigateAlbumDetailBack } from '@/utils/navigation/albumDetailNavigation';
vi.mock('@/utils/navigation/albumDetailNavigation', async (importOriginal) => {
const mod = await importOriginal<typeof import('@/utils/navigation/albumDetailNavigation')>();
return {
...mod,
navigateAlbumDetailBack: vi.fn(mod.navigateAlbumDetailBack),
};
});
function detailWrapper(initialState: unknown) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<MemoryRouter initialEntries={[{ pathname: '/album/al-1', state: initialState }]}>
<Routes>
<Route path="/album/:id" element={children} />
</Routes>
</MemoryRouter>
);
};
}
describe('useAlbumDetailBack', () => {
beforeEach(() => {
vi.mocked(navigateAlbumDetailBack).mockClear();
});
it('routes browser back through navigateAlbumDetailBack when returnTo exists', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState');
renderHook(() => useAlbumDetailBack(), {
wrapper: detailWrapper({ returnTo: '/search/advanced' }),
});
expect(pushStateSpy).toHaveBeenCalled();
act(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(navigateAlbumDetailBack).toHaveBeenCalledTimes(1);
pushStateSpy.mockRestore();
});
it('does not trap browser back when returnTo is missing', () => {
const pushStateSpy = vi.spyOn(window.history, 'pushState');
renderHook(() => useAlbumDetailBack(), {
wrapper: detailWrapper(null),
});
expect(pushStateSpy).not.toHaveBeenCalled();
act(() => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(navigateAlbumDetailBack).not.toHaveBeenCalled();
pushStateSpy.mockRestore();
});
});
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
navigateAlbumDetailBack,
readAlbumDetailReturnTo,
} from '@/utils/navigation/albumDetailNavigation';
/** Leave album/artist detail for the page that opened it (or history back as fallback). */
export function useAlbumDetailBack(fallback = '/') {
const navigate = useNavigate();
const location = useLocation();
const locationStateRef = useRef(location.state);
// 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
locationStateRef.current = location.state;
const goBack = useCallback(
() => navigateAlbumDetailBack(navigate, location, fallback),
[navigate, location, fallback],
);
useEffect(() => {
const returnTo = readAlbumDetailReturnTo(locationStateRef.current);
if (!returnTo) return;
const trapUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.history.pushState({ psysonicDetailBackTrap: true }, '', trapUrl);
const onPopState = () => {
navigateAlbumDetailBack(
navigate,
{ state: locationStateRef.current },
fallback,
);
};
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, [navigate, location.pathname, location.search, location.hash, fallback]);
return goBack;
}
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import {
loadAlbumFromLibraryIndex,
loadArtistFromLibraryIndex,
} from '@/features/offline';
import {
resolveAlbum,
resolveArtist,
type ResolvedAlbum,
} from '@/features/offline';
import { useOfflineBrowseContext } from '@/features/offline';
import {
loadArtistFromLocalPlayback,
offlineLocalBrowseEnabled,
} from '@/features/offline';
import { readDetailServerId } from '@/utils/navigation/detailServerScope';
import { libraryIsReady } from '@/utils/library/libraryReady';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '@/utils/network/subsonicNetworkGuard';
type AlbumPayload = ResolvedAlbum;
interface UseAlbumDetailDataResult {
album: AlbumPayload | null;
setAlbum: React.Dispatch<React.SetStateAction<AlbumPayload | null>>;
relatedAlbums: SubsonicAlbum[];
loading: boolean;
isStarred: boolean;
setIsStarred: (v: boolean) => void;
starredSongs: Set<string>;
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
}
/**
* Load an album payload by id, then resolve the artist's other albums in
* a follow-up call so the related-albums grid can render without blocking
* the initial paint.
*/
export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataResult {
const [album, setAlbum] = useState<AlbumPayload | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
useEffect(() => {
if (!id) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setRelatedAlbums([]);
const applyAlbumPayload = (data: AlbumPayload) => {
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
};
const loadRelatedAlbums = async (
serverId: string | null,
artistId: string | undefined,
useLocalArtist: boolean,
localBytesOnly: boolean,
) => {
if (!artistId) return;
try {
if (useLocalArtist && serverId) {
const artistLocal = localBytesOnly
? await loadArtistFromLocalPlayback(serverId, artistId)
: await loadArtistFromLibraryIndex(serverId, artistId);
if (artistLocal) {
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
return;
}
}
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
if (!relatedServerId) return;
const artistData = await resolveArtist(relatedServerId, artistId);
if (artistData) {
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
}
} catch (e) {
console.error('Failed to fetch related albums', e);
}
};
void (async () => {
if (offlineBrowseActive && detailServerId) {
const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(
detailServerId,
local.album.artistId,
true,
offlineLocalBrowseEnabled(detailServerId),
);
return;
}
setLoading(false);
return;
}
// Index-first when the local SQLite index is ready, not only when the
// favorites-offline toggle is on — album detail then opens from SQLite
// (and offline) with the same genres genre browse derives.
const indexReady = !!detailServerId && await libraryIsReady(detailServerId);
const canLoadLocal = (favoritesOfflineEnabled || indexReady) && !!detailServerId;
if (canLoadLocal && detailServerId) {
try {
const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
return;
}
} catch { /* fall through */ }
}
const detailNetworkAllowed = detailServerId
? shouldAttemptSubsonicForServer(detailServerId)
: shouldAttemptSubsonicForActiveServer();
if (!detailNetworkAllowed) {
if (canLoadLocal && detailServerId) {
try {
const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
return;
}
try {
const sid = detailServerId ?? activeServerId;
if (!sid) {
setLoading(false);
return;
}
const data = await resolveAlbum(sid, id);
if (!data) {
setLoading(false);
return;
}
applyAlbumPayload(data);
await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
} catch {
if (canLoadLocal && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
}
})();
}, [activeServerId, detailServerId, favoritesOfflineEnabled, id, offlineBrowseActive, searchParams]);
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
}
@@ -0,0 +1,98 @@
import { useMemo, useState } from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
export type AlbumSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
interface UseAlbumDetailSortArgs {
songs: SubsonicSong[] | undefined;
filterText: string;
starredSongs: Set<string>;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
}
interface UseAlbumDetailSortResult {
sortKey: AlbumSortKey;
sortDir: 'asc' | 'desc';
handleSort: (key: AlbumSortKey) => void;
displayedSongs: SubsonicSong[];
}
/**
* Sort + text-filter pipeline for the album track list. Click cycle on a
* header column: asc → desc → off (back to `natural` order). Other
* columns reset to asc on first click.
*
* Rating reads use the same priority as the row renderer
* (`ratings[id] ?? userRatingOverrides[id] ?? song.userRating`) so the
* sort matches the visible stars during optimistic updates.
*/
export function useAlbumDetailSort({
songs,
filterText,
starredSongs,
ratings,
userRatingOverrides,
}: UseAlbumDetailSortArgs): UseAlbumDetailSortResult {
const [sortKey, setSortKey] = useState<AlbumSortKey>('natural');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [sortClickCount, setSortClickCount] = useState(0);
const handleSort = (key: AlbumSortKey) => {
if (key === 'natural') return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
setSortKey(key);
setSortDir('asc');
setSortClickCount(1);
}
};
const displayedSongs = useMemo(() => {
if (!songs) return [];
const q = filterText.trim().toLowerCase();
if (!q && sortKey === 'natural') return songs;
let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
switch (sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
case 'favorite':
av = starredSongs.has(a.id) ? 1 : 0;
bv = starredSongs.has(b.id) ? 1 : 0;
break;
case 'rating':
av = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
bv = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
break;
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
case 'playCount': av = a.playCount ?? 0; bv = b.playCount ?? 0; break;
case 'lastPlayed': av = a.played ? Date.parse(a.played) || 0 : 0; bv = b.played ? Date.parse(b.played) || 0 : 0; break;
case 'bpm': av = a.bpm ?? 0; bv = b.bpm ?? 0; break;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
return sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}, [songs, filterText, sortKey, sortDir, starredSongs, ratings, userRatingOverrides]);
return { sortKey, sortDir, handleSort, displayedSongs };
}
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
type AlbumBrowseReturnFilters,
type AlbumBrowseSurface,
albumBrowseSurfaceForPath,
isAlbumDetailPath,
useAlbumBrowseSessionStore,
} from '@/features/album/store/albumBrowseSessionStore';
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
import {
inpageScrollViewportIdForSurface,
readInpageScrollTop,
} from '@/constants/appScroll';
import type { AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
export type AlbumGridBrowseSnapshot = {
albums: SubsonicAlbum[];
hasMore: boolean;
};
function sameGenreSelection(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function returnStateForNavigation(
serverId: string,
surface: AlbumBrowseSurface,
navigationType: NavigationType,
locationState: unknown,
): AlbumBrowseReturnFilters {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) {
return DEFAULT_ALBUM_BROWSE_RETURN_FILTERS;
}
return (
useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface)
?? DEFAULT_ALBUM_BROWSE_RETURN_FILTERS
);
}
/** Genre-filter album grid pages (New Releases, Random Albums) — shared leave-restore. */
export function useAlbumGridBrowseFilters(
serverId: string,
surface: AlbumBrowseSurface,
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
gridSnapshotRef?: RefObject<AlbumGridBrowseSnapshot>,
) {
const navigationType = useNavigationType();
const location = useLocation();
const initialState = returnStateForNavigation(serverId, surface, navigationType, location.state);
const [selectedGenres, setSelectedGenres] = useState<string[]>(() => initialState.selectedGenres);
const restoredFromStashRef = useRef(false);
const filtersRef = useRef({ selectedGenres, searchQuery: '' });
// 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
filtersRef.current = {
selectedGenres,
searchQuery: useLiveSearchScopeStore.getState().query,
};
useEffect(() => {
restoredFromStashRef.current = false;
}, [serverId, surface]);
useEffect(() => {
if (!serverId) return;
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
restoredFromStashRef.current = true;
const restored = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (restored) {
useLiveSearchScopeStore.getState().setQuery(restored.searchQuery ?? '');
if (!sameGenreSelection(restored.selectedGenres, filtersRef.current.selectedGenres)) {
setSelectedGenres(restored.selectedGenres);
}
}
return;
}
if (restoredFromStashRef.current) return;
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
useLiveSearchScopeStore.getState().setQuery('');
setSelectedGenres([]);
}, [serverId, surface, navigationType, location.state]);
useEffect(() => {
return () => {
if (!serverId) return;
const path = window.location.pathname;
if (isAlbumDetailPath(path)) {
// Read at cleanup time on purpose: we want the snapshots as they are at
// navigation-away. Copying them at effect setup would stash stale values.
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollSnapshot = scrollSnapshotRef?.current;
// eslint-disable-next-line react-hooks/exhaustive-deps
const gridSnapshot = gridSnapshotRef?.current;
const viewportId = inpageScrollViewportIdForSurface(surface);
const scrollTop = Math.max(
readInpageScrollTop(viewportId),
scrollSnapshot?.scrollTop ?? 0,
);
useAlbumBrowseSessionStore.getState().stashReturnFilters(serverId, surface, {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: filtersRef.current.selectedGenres,
searchQuery: filtersRef.current.searchQuery,
scrollTop,
displayCount: gridSnapshot?.albums.length ?? scrollSnapshot?.displayCount,
albums: gridSnapshot?.albums,
hasMore: gridSnapshot?.hasMore,
});
} else if (albumBrowseSurfaceForPath(path) !== surface) {
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
}
};
}, [serverId, surface, scrollSnapshotRef, gridSnapshotRef]);
return {
selectedGenres,
setSelectedGenres,
initialAlbums: initialState.albums ?? null,
initialHasMore: initialState.hasMore,
};
}
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useOfflineJobStore } from '@/features/offline';
import { useAlbumOfflineState } from '@/features/album/hooks/useAlbumOfflineState';
describe('useAlbumOfflineState', () => {
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
});
it('reports queued when the album waits in the pin queue', () => {
useOfflineJobStore.setState({
pinQueue: [{
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
status: 'queued',
queuedAt: Date.now(),
}],
});
const { result } = renderHook(() => useAlbumOfflineState('alb-1', 'srv', ['t1']));
expect(result.current.resolvedOfflineStatus).toBe('queued');
expect(result.current.offlineProgress).toBeNull();
});
it('prefers downloading over queued when jobs are active', () => {
useOfflineJobStore.setState({
pinQueue: [{
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
status: 'downloading',
queuedAt: Date.now(),
}],
jobs: [{
trackId: 't1',
albumId: 'alb-1',
albumName: 'One',
trackTitle: 'Track',
trackIndex: 0,
totalTracks: 1,
status: 'downloading',
downloadId: 'dl-1',
}],
});
const { result } = renderHook(() => useAlbumOfflineState('alb-1', 'srv', ['t1']));
expect(result.current.resolvedOfflineStatus).toBe('downloading');
expect(result.current.offlineProgress).toEqual({ done: 0, total: 1 });
});
});
@@ -0,0 +1,69 @@
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineJobStore } from '@/features/offline';
import { isOfflinePinComplete } from '@/features/offline';
export type AlbumOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
interface UseAlbumOfflineStateResult {
resolvedOfflineStatus: AlbumOfflineStatus;
offlineProgress: { done: number; total: number } | null;
}
/**
* Combined offline-cache status for an album. Splits the read across
* three primitive Zustand selectors so the page only re-renders when
* one of the resolved scalars (status / done count / total count)
* actually flips — not on every `jobs` array mutation during batch
* downloads (each track flip would otherwise trigger a full page render).
*
* Resolution rules:
* - Fully pinned → `cached`.
* - Active pin jobs or pin-queue `downloading` → `downloading` + progress.
* - Pin-queue `queued` (waiting behind another album) → `queued`.
* - Else `none`.
*
* `albumId` is allowed to be empty (e.g. while the page is still
* fetching) — in that case every selector short-circuits to a benign
* default.
*/
export function useAlbumOfflineState(
albumId: string,
serverId: string,
songIds?: string[],
): UseAlbumOfflineStateResult {
useLocalPlaybackStore(s => s.entries);
const pinComplete = !!albumId && isOfflinePinComplete(albumId, serverId, songIds);
const isPinQueued = useOfflineJobStore(s =>
!pinComplete
&& !!albumId
&& s.pinQueue.some(p => p.albumId === albumId && p.status === 'queued'),
);
const isOfflineDownloading = useOfflineJobStore(s =>
!pinComplete
&& !!albumId
&& (
s.pinQueue.some(p => p.albumId === albumId && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
),
);
const offlineProgressDone = useOfflineJobStore(s => {
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => {
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId).length;
});
const resolvedOfflineStatus = pinComplete
? 'cached'
: isOfflineDownloading
? 'downloading'
: isPinQueued
? 'queued'
: 'none';
const offlineProgress = isOfflineDownloading && offlineProgressTotal > 0
? { done: offlineProgressDone, total: offlineProgressTotal }
: null;
return { resolvedOfflineStatus, offlineProgress };
}
@@ -0,0 +1,108 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { useSelectionStore } from '@/store/selectionStore';
import { useDragDrop } from '@/contexts/DragDropContext';
import { songToTrack } from '@/utils/playback/songToTrack';
interface UseAlbumTrackListSelectionArgs {
songs: SubsonicSong[];
tracklistRef: React.RefObject<HTMLDivElement | null>;
}
interface UseAlbumTrackListSelectionResult {
inSelectMode: boolean;
allSelected: boolean;
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
toggleAll: () => void;
}
/**
* Bulk selection + drag wiring for `AlbumTrackList`:
* - Clears selection whenever the song list changes (album switch or
* filter applied) and on mousedown outside the tracklist — but not when
* the click lands on the bulk-action toolbar, which belongs to the selection.
* - `onToggleSelect` supports shift-click ranges anchored against the
* last toggled row.
* - `onDragStart` promotes a single-row drag into a multi-row drag when
* the dragged song is part of the active selection.
*
* Subscribes only to `selectedIds.size` so the host component re-renders
* once when select-mode flips on/off; per-row state stays inside
* `TrackRow`'s own primitive selector for O(1) toggles.
*/
export function useAlbumTrackListSelection({
songs,
tracklistRef,
}: UseAlbumTrackListSelectionArgs): UseAlbumTrackListSelectionResult {
const psyDrag = useDragDrop();
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const inSelectMode = selectedCount > 0;
const allSelected = selectedCount === songs.length && songs.length > 0;
const lastSelectedIdxRef = useRef<number | null>(null);
useEffect(() => {
useSelectionStore.getState().clearAll();
lastSelectedIdxRef.current = null;
}, [songs]);
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!tracklistRef.current || tracklistRef.current.contains(target)) return;
// The bulk-action toolbar (filter, add-to-playlist picker, clear button)
// renders outside the tracklist DOM but belongs to the selection — a
// mousedown there must not wipe the selection before the button's own
// click handler runs (otherwise "Add to playlist" clears and never opens).
if (target.closest('.album-track-toolbar')) return;
useSelectionStore.getState().clearAll();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode, tracklistRef]);
const onToggleSelect = useCallback((id: string, globalIdx: number, shift: boolean) => {
useSelectionStore.getState().setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
} else {
if (next.has(id)) next.delete(id);
else next.add(id);
}
lastSelectedIdxRef.current = globalIdx;
return next;
});
}, [songs]);
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
const { selectedIds } = useSelectionStore.getState();
if (selectedIds.has(song.id) && selectedIds.size > 1) {
const tracks = songs
.filter(s => selectedIds.has(s.id))
.map(s => songToTrack(s));
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
me.clientX, me.clientY,
);
} else {
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'song', track: songToTrack(song) }), label: song.title },
me.clientX, me.clientY,
);
}
}, [songs, psyDrag]);
const toggleAll = useCallback(() => {
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
}
}, [allSelected, songs]);
return { inSelectMode, allSelected, onToggleSelect, onDragStart, toggleAll };
}
@@ -0,0 +1,87 @@
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { useEffect, useRef, useState } from 'react';
import {
BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
BROWSE_TEXT_DEBOUNCE_RACE_MS,
browseRaceCountsAlbums,
raceBrowseWithLocalFallback,
runLocalBrowseAlbums,
runNetworkBrowseAlbums,
} from '@/utils/library/browseTextSearch';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineLocalBrowseEnabled, searchOfflineLocalAlbums } from '@/features/offline';
/**
* Debounced album title search with local-vs-network race when the
* library index is enabled; network-only when it is not.
*/
export function useBrowseAlbumTextSearch(
filter: string,
indexEnabled: boolean,
serverId: string | null | undefined,
losslessOnly = false,
) {
const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchAlbums, setTextSearchAlbums] = useState<SubsonicAlbum[] | null>(null);
const [textSearchLoading, setTextSearchLoading] = useState(false);
const searchGenRef = useRef(0);
useEffect(() => {
const ms = indexEnabled ? BROWSE_TEXT_DEBOUNCE_RACE_MS : BROWSE_TEXT_DEBOUNCE_NETWORK_MS;
const timer = window.setTimeout(() => setDebouncedFilter(filter.trim()), ms);
return () => window.clearTimeout(timer);
}, [filter, indexEnabled]);
useEffect(() => {
const q = debouncedFilter;
if (!q || !serverId) {
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
setTextSearchAlbums(null);
setTextSearchLoading(false);
return;
}
const gen = ++searchGenRef.current;
const isStale = () => gen !== searchGenRef.current;
setTextSearchLoading(true);
void (async () => {
if (offlineBrowseActive) {
const albums = offlineLocalBrowseEnabled(serverId)
? await searchOfflineLocalAlbums(serverId, q, losslessOnly)
: [];
if (isStale()) return;
setTextSearchAlbums(albums);
setTextSearchLoading(false);
return;
}
if (!indexEnabled) {
const albums = await runNetworkBrowseAlbums(q);
if (isStale()) return;
setTextSearchAlbums(albums);
setTextSearchLoading(false);
return;
}
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseAlbums(serverId, q, undefined, losslessOnly),
() => runNetworkBrowseAlbums(q),
{
surface: 'albums_browse',
query: q,
indexEnabled,
counts: browseRaceCountsAlbums,
},
);
if (isStale()) return;
setTextSearchAlbums(outcome?.result ?? null);
setTextSearchLoading(false);
})();
}, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]);
const effectiveFilter = textSearchAlbums != null ? '' : filter;
return { textSearchAlbums, textSearchLoading, effectiveFilter };
}
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { dedupeById } from '@/utils/dedupeById';
import type { AlbumBrowseSort } from '@/utils/library/albumBrowseSort';
import {
fetchGenreAlbumPage,
GENRE_ALBUM_CATALOG_CHUNK,
GENRE_ALBUM_FIRST_PAGE,
} from '@/utils/library/genreAlbumBrowse';
import { useClientSliceInfiniteScroll } from '@/hooks/useClientSliceInfiniteScroll';
import { useInpageScrollSentinel } from '@/hooks/useInpageScrollSentinel';
const CLIENT_SLICE_PAGE_SIZE = GENRE_ALBUM_FIRST_PAGE;
function initialSqlPageSize(restoreDisplayCount?: number): number {
if (restoreDisplayCount != null && restoreDisplayCount > CLIENT_SLICE_PAGE_SIZE) {
return Math.min(restoreDisplayCount, GENRE_ALBUM_CATALOG_CHUNK);
}
return CLIENT_SLICE_PAGE_SIZE;
}
export function useGenreAlbumBrowse(
serverId: string,
genre: string,
indexEnabled: boolean,
sort: AlbumBrowseSort,
musicLibraryFilterVersion: number,
getScrollRoot?: () => HTMLElement | null,
scrollRootEl?: HTMLElement | null,
restoreDisplayCount?: number,
) {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
const [catalogHasMore, setCatalogHasMore] = useState(false);
const catalogOffsetRef = useRef(0);
const catalogLoadingRef = useRef(false);
const loadGenerationRef = useRef(0);
const loadingRef = useRef(false);
const loadPendingRef = useRef(false);
const loadMoreRef = useRef<() => void>(() => {});
const browseSessionRef = useRef({ key: '', restoreDisplayCount: undefined as number | undefined });
const browseKey = `${serverId}:${genre}`;
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
if (browseSessionRef.current.key !== browseKey) {
// 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
browseSessionRef.current = {
key: browseKey,
restoreDisplayCount: restoreDisplayCount,
};
}
const sessionRestoreDisplayCount = browseSessionRef.current.restoreDisplayCount;
const {
visibleCount,
loadingMore: sliceLoadingMore,
loadMore: sliceLoadMore,
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
} = useClientSliceInfiniteScroll({
pageSize: CLIENT_SLICE_PAGE_SIZE,
resetDeps: [
sort,
genre,
musicLibraryFilterVersion,
serverId,
indexEnabled,
],
getScrollRoot,
scrollRootEl,
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
restoreDisplayCount: sessionRestoreDisplayCount,
});
const displayAlbums = useMemo(
() => albums.slice(0, visibleCount),
[albums, visibleCount],
);
const hasMore = visibleCount < albums.length || catalogHasMore;
const loadingMore = sliceLoadingMore || catalogLoadingMore;
const loadCatalogChunk = useCallback(async (
offset: number,
append: boolean,
pageSize: number = GENRE_ALBUM_CATALOG_CHUNK,
) => {
if (catalogLoadingRef.current || !genre) return;
const generation = loadGenerationRef.current;
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
const chunk = await fetchGenreAlbumPage(
serverId,
genre,
indexEnabled,
offset,
pageSize,
sort,
);
if (generation !== loadGenerationRef.current) return;
if (append) {
setAlbums(prev => {
const merged = dedupeById([...prev, ...chunk.albums]);
catalogOffsetRef.current = merged.length;
return merged;
});
} else {
setAlbums(chunk.albums);
catalogOffsetRef.current = chunk.albums.length;
}
setCatalogHasMore(chunk.hasMore);
} finally {
catalogLoadingRef.current = false;
if (generation === loadGenerationRef.current) {
setCatalogLoadingMore(false);
}
}
}, [serverId, genre, indexEnabled, sort]);
useEffect(() => {
if (!genre) {
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAlbums([]);
setCatalogHasMore(false);
setLoading(false);
return;
}
let cancelled = false;
loadGenerationRef.current += 1;
const generation = loadGenerationRef.current;
catalogOffsetRef.current = 0;
catalogLoadingRef.current = false;
loadingRef.current = true;
loadPendingRef.current = true;
setLoading(true);
setCatalogLoadingMore(false);
setCatalogHasMore(false);
setAlbums([]);
const firstPageSize = initialSqlPageSize(sessionRestoreDisplayCount);
void loadCatalogChunk(0, false, firstPageSize).finally(() => {
if (cancelled || generation !== loadGenerationRef.current) return;
loadingRef.current = false;
loadPendingRef.current = false;
setLoading(false);
});
return () => {
cancelled = true;
};
// sessionRestoreDisplayCount is read once to restore the prior visible count;
// the catalog load must not re-run when it later changes, so it is excluded.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loadCatalogChunk]);
const loadMore = useCallback(() => {
if (!genre || loadingRef.current || loadPendingRef.current) return;
if (visibleCount < albums.length) {
sliceLoadMore();
return;
}
if (catalogHasMore && !catalogLoadingRef.current) {
void loadCatalogChunk(catalogOffsetRef.current, true);
}
}, [genre, visibleCount, albums.length, catalogHasMore, sliceLoadMore, loadCatalogChunk]);
// 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
loadMoreRef.current = loadMore;
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: hasMore,
getScrollRoot,
scrollRootEl,
onIntersect: () => loadMoreRef.current(),
drainSignal: loadingMore,
});
return {
albums,
displayAlbums,
loading,
loadingMore,
hasMore,
loadMore,
bindLoadMoreSentinel,
};
}
@@ -0,0 +1,15 @@
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { navigateToAlbumDetail } from '@/utils/navigation/albumDetailNavigation';
/** Navigate to album detail, remembering the current page for the back button. */
export function useNavigateToAlbum() {
const navigate = useNavigate();
const location = useLocation();
return useCallback(
(albumId: string, opts?: { search?: string }) => {
navigateToAlbumDetail(navigate, location, albumId, opts);
},
[navigate, location],
);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Album feature — the Albums/Random/Lossless/Label browse pages + AlbumDetail
* (all lazy via deep `pages/*`, not re-exported), album cards/rows/header, the
* album track list + its mobile/header/row pieces, album detail + browse hooks,
* the album session store, per-album offline status, and album export helpers.
*
* Stays OUT (library-core / cross-cutting, consumed by this feature, not owned):
* the `utils/library/album*` browse query engine (shared with offline + other
* browse surfaces → M4), `albumDetailNavigation` (cross-cutting nav → M4),
* `playAlbum` (playback action → playback), `starredAlbumIndexSync` (index
* sync, core), the album context-menu items (context-menu subsystem), the
* shared `TracklistColumnPicker` (also used by favorites), and `cover/*`.
*/
export * from './api/subsonicAlbumInfo';
export * from './hooks/useAlbumBrowseData';
export * from './hooks/useAlbumBrowseFilters';
export * from './hooks/useAlbumBrowseScrollReset';
export * from './hooks/useAlbumBrowseScrollRestore';
export * from './hooks/useAlbumCatalogYearBounds';
export * from './hooks/useAlbumDetailBack';
export * from './hooks/useAlbumDetailData';
export * from './hooks/useAlbumDetailSort';
export * from './hooks/useAlbumGridBrowseFilters';
export * from './hooks/useAlbumOfflineState';
export * from './hooks/useAlbumTrackListSelection';
export * from './hooks/useBrowseAlbumTextSearch';
export * from './hooks/useGenreAlbumBrowse';
export * from './hooks/useNavigateToAlbum';
export * from './store/albumBrowseSessionStore';
export * from './utils/albumDetailHelpers';
export * from './utils/albumRecency';
export * from './utils/albumTrackListHelpers';
export * from './utils/deriveAlbumHeaderArtistRefs';
export * from './utils/exportAlbumCard';
export * from './utils/exportNewAlbums';
export { default as AlbumCard } from './components/AlbumCard';
export { default as AlbumHeader } from './components/AlbumHeader';
export { default as AlbumRow } from './components/AlbumRow';
export { default as AlbumTrackList } from './components/AlbumTrackList';
export { default as LosslessAlbumsRail } from './components/LosslessAlbumsRail';
+440
View File
@@ -0,0 +1,440 @@
import { buildDownloadUrl } from '@/api/subsonicStreamUrl';
import { setRating, star, unstar } from '@/api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '@/store/pendingStarSync';
import { getAlbumForServer } from '@/api/subsonicLibrary';
import { getArtistInfo } from '@/features/artist';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { shuffleArray } from '@/utils/playback/shuffleArray';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useOrbitSongRowBehavior } from '@/features/orbit';
import { useAlbumDetailData } from '@/features/album/hooks/useAlbumDetailData';
import { useAlbumOfflineState } from '@/features/album/hooks/useAlbumOfflineState';
import { useAlbumDetailSort } from '@/features/album/hooks/useAlbumDetailSort';
import { useDownloadModalStore } from '@/features/offline';
import { useOfflineStore } from '@/features/offline';
import { useOfflineJobStore } from '@/features/offline';
import { isOfflinePinComplete } from '@/features/offline';
import { dequeueOfflinePin } from '@/features/offline';
import { reconcileLibraryTierForAlbum } from '@/features/offline';
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
import { join } from '@tauri-apps/api/path';
import { useZipDownloadStore } from '@/features/offline';
import AlbumCard from '@/features/album/components/AlbumCard';
import AlbumHeader from '@/features/album/components/AlbumHeader';
import AlbumTrackList from '@/features/album/components/AlbumTrackList';
import { AlbumDetailToolbar } from '@/features/album/components/AlbumDetailToolbar';
import { useCoverArt } from '@/cover/useCoverArt';
import {
forgetAlbumDistinctDiscCovers,
rememberAlbumDistinctDiscCovers,
} from '@/cover/ref';
import { useAlbumCoverRef } from '@/cover/useLibraryCoverRef';
import { useTranslation } from 'react-i18next';
import { showToast } from '@/utils/ui/toast';
import { useSelectionStore } from '@/store/selectionStore';
import { sanitizeFilename } from '@/features/album/utils/albumDetailHelpers';
import { albumArtistDisplayName, deriveAlbumHeaderArtistRefs } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
import LosslessModeBanner from '@/components/LosslessModeBanner';
import { isLosslessSuffix } from '@/utils/library/losslessFormats';
import { isLosslessMode } from '@/utils/library/losslessMode';
import { readDetailServerId } from '@/utils/navigation/detailServerScope';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
export default function AlbumDetail() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const losslessOnly = isLosslessMode(searchParams);
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const {
album, setAlbum, relatedAlbums, loading,
isStarred, setIsStarred, starredSongs, setStarredSongs,
} = useAlbumDetailData(id);
const [ratings, setRatings] = useState<Record<string, number>>({});
const [bio, setBio] = useState<string | null>(null);
const [bioOpen, setBioOpen] = useState(false);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const serverId = readDetailServerId(searchParams, auth.activeServerId) ?? '';
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const offlineCtx = useOfflineBrowseContext();
const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
const [albumEntityRating, setAlbumEntityRating] = useState(0);
const [filterText, setFilterText] = useState('');
const [showPlPicker, setShowPlPicker] = useState(false);
const selectedCount = useSelectionStore(s => s.selectedIds.size);
const inSelectMode = selectedCount > 0;
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
const albumId = album?.album.id ?? '';
useEffect(() => {
if (!id) return;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (album && album.album.id === id) setAlbumEntityRating(album.album.userRating ?? 0);
// Keyed on the album's id / userRating primitives; depending on the `album`
// object would re-run on every render when its identity changes but those do not.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, album?.album.id, album?.album.userRating]);
// React Compiler rule: manual memoization is intentional and must be preserved.
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const effectiveSongs = useMemo(() => {
if (!album?.songs) return undefined;
if (!losslessOnly) return album.songs;
return album.songs.filter(s => isLosslessSuffix(s.suffix));
}, [album?.songs, losslessOnly]);
const offlineSongIds = useMemo(
() => (effectiveSongs ?? album?.songs ?? []).map(s => s.id),
[effectiveSongs, album?.songs],
);
const { resolvedOfflineStatus, offlineProgress } = useAlbumOfflineState(albumId, serverId, offlineSongIds);
useEffect(() => {
if (!albumId || !album || offlineSongIds.length === 0) return;
const songs = effectiveSongs ?? album.songs;
let cancelled = false;
void reconcileLibraryTierForAlbum(
serverId,
songs,
{ kind: 'album', sourceId: albumId, displayName: album.album.name },
).then(() => {
if (cancelled) return;
if (!isOfflinePinComplete(albumId, serverId, offlineSongIds)) return;
useOfflineJobStore.setState(s => ({
jobs: s.jobs.filter(j => j.albumId !== albumId),
}));
});
return () => { cancelled = true; };
}, [albumId, serverId, album, effectiveSongs, offlineSongIds]);
useEffect(() => {
if (!albumId || !effectiveSongs?.length) return;
rememberAlbumDistinctDiscCovers(albumId, effectiveSongs);
return () => forgetAlbumDistinctDiscCovers(albumId);
}, [albumId, effectiveSongs]);
const handlePlayAll = () => {
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
if (tracks[0]) playTrack(tracks[0], tracks);
};
const handleEnqueueAll = () => {
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
enqueue(tracks);
};
const handleShuffleAll = () => {
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
const shuffled = shuffleArray(tracks);
if (shuffled[0]) playTrack(shuffled[0], shuffled);
};
const { orbitActive, queueHint, addTrackToOrbit } = useOrbitSongRowBehavior();
const handlePlaySong = (song: SubsonicSong) => {
if (orbitActive) { queueHint(); return; }
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
playTrack(track, tracks);
};
const handleDoubleClickSong = (song: SubsonicSong) => addTrackToOrbit(song.id);
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating);
};
const handleAlbumEntityRating = async (rating: number) => {
if (!album || album.album.id !== id) return;
const albumId = album.album.id;
const ratingAtStart = album.album.userRating ?? 0;
setAlbumEntityRating(rating);
if (albumEntityRatingSupport !== 'full') return;
try {
await setRating(albumId, rating);
setAlbum(cur =>
cur && cur.album.id === albumId
? { ...cur, album: { ...cur.album, userRating: rating } }
: cur,
);
} catch (err) {
setAlbumEntityRating(ratingAtStart);
setEntityRatingSupport(serverId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
'error',
);
}
};
const handleBio = async () => {
if (!album) return;
if (bio) { setBioOpen(true); return; }
const info = await getArtistInfo(album.album.artistId);
setBio(info.biography ?? t('albumDetail.noBio'));
setBioOpen(true);
};
const handleDownload = async () => {
if (!album) return;
const { name, id: albumId } = album.album;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const filename = `${sanitizeFilename(name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const downloadId = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed:', e);
}
};
const toggleStar = async () => {
if (!album) return;
const wasStarred = isStarred;
setIsStarred(!wasStarred);
try {
const meta = {
serverId: serverId || album.album.serverId,
name: album.album.name,
artist: album.album.artist,
artistId: album.album.artistId,
coverArtId: album.album.coverArt,
year: album.album.year,
};
if (wasStarred) await unstar(album.album.id, 'album', meta);
else await star(album.album.id, 'album', meta);
} catch (e) {
console.error('Failed to toggle star', e);
setIsStarred(wasStarred);
}
};
const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const wasStarred = starredSongs.has(song.id);
const next = new Set(starredSongs);
if (wasStarred) next.delete(song.id); else next.add(song.id);
setStarredSongs(next);
// F4: optimistic override + retried server sync via the central helper.
queueSongStar(song.id, !wasStarred, song.serverId ?? (serverId || undefined));
};
const handleCacheOffline = useCallback(async () => {
if (!album) return;
if (resolvedOfflineStatus === 'queued') {
dequeueOfflinePin(album.album.id);
return;
}
let songs = effectiveSongs ?? album.songs;
if (serverId && shouldAttemptSubsonicForServer(serverId)) {
try {
const fresh = await getAlbumForServer(serverId, album.album.id);
songs = losslessOnly
? fresh.songs.filter(s => isLosslessSuffix(s.suffix))
: fresh.songs;
} catch {
/* keep album.songs from the page */
}
}
if (isOfflinePinComplete(album.album.id, serverId, songs.map(s => s.id))) return;
downloadAlbum(album.album.id, album.album.name, albumArtistDisplayName(album.album), album.album.coverArt, album.album.year, songs, serverId);
}, [album, downloadAlbum, serverId, effectiveSongs, losslessOnly, resolvedOfflineStatus]);
const handleRemoveOffline = () => {
if (!album) return;
deleteAlbum(album.album.id, serverId);
};
// Must be before early returns — hooks must be called unconditionally.
const mergedStarredSongs = useMemo(() => new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
]), [starredSongs, starredOverrides]);
const { sortKey, sortDir, handleSort, displayedSongs } = useAlbumDetailSort({
songs: effectiveSongs,
filterText,
starredSongs: mergedStarredSongs,
ratings,
userRatingOverrides,
});
const albumCoverRefResolved = useAlbumCoverRef(
album?.album.id,
album?.album.coverArt,
undefined,
{ libraryResolve: true },
);
const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' });
const resolvedCoverUrl = albumCover.src || null;
useEffect(() => {
if (!showPlPicker) return;
const handler = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.bulk-pl-picker-wrap')) setShowPlPicker(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
useEffect(() => {
// React Compiler set-state-in-effect rule: state set from an external subscription/event callback.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!inSelectMode) setShowPlPicker(false);
}, [inSelectMode]);
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
const { album: info } = album;
const songs = effectiveSongs ?? [];
const headerArtistRefs = deriveAlbumHeaderArtistRefs(info, songs);
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
return (
<div className="album-detail animate-fade-in">
<AlbumHeader
info={info}
headerArtistRefs={headerArtistRefs}
songs={songs}
coverArtId={info.coverArt}
resolvedCoverUrl={resolvedCoverUrl}
isStarred={isStarred}
downloadProgress={null}
bio={bio}
bioOpen={bioOpen}
onToggleStar={toggleStar}
onDownload={handleDownload}
onPlayAll={handlePlayAll}
onEnqueueAll={handleEnqueueAll}
onShuffleAll={handleShuffleAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={resolvedOfflineStatus}
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
entityRatingValue={albumEntityRating}
onEntityRatingChange={handleAlbumEntityRating}
entityRatingSupport={albumEntityRatingSupport}
actionPolicy={albumActionPolicy}
/>
{losslessOnly && <LosslessModeBanner />}
{songs.length > 0 && (
<AlbumDetailToolbar
filterText={filterText}
setFilterText={setFilterText}
inSelectMode={inSelectMode}
selectedCount={selectedCount}
showPlPicker={showPlPicker}
setShowPlPicker={setShowPlPicker}
t={t}
actionPolicy={albumActionPolicy}
/>
)}
<AlbumTrackList
songs={displayedSongs}
discTitles={album?.album.discTitles}
sorted={sortKey !== 'natural' || !!filterText.trim()}
hasVariousArtists={hasVariousArtists}
currentTrack={currentTrack}
isPlaying={isPlaying}
ratings={ratings}
userRatingOverrides={userRatingOverrides}
starredSongs={mergedStarredSongs}
onPlaySong={handlePlaySong}
onDoubleClickSong={orbitActive ? handleDoubleClickSong : undefined}
onRate={handleRate}
onToggleSongStar={toggleSongStar}
onContextMenu={openContextMenu}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
actionPolicy={albumActionPolicy}
/>
{relatedAlbums.length > 0 && (
<div className="album-related">
<div className="album-related-divider" />
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<VirtualCardGrid
items={relatedAlbums}
itemKey={(a, i) => `${a.id}-${i}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={relatedAlbums.length}
warmGridCovers={albumGridWarmCovers()}
renderItem={a => <AlbumCard album={a} />}
/>
</div>
)}
</div>
);
}
+557
View File
@@ -0,0 +1,557 @@
import { buildDownloadUrl } from '@/api/subsonicStreamUrl';
import { resolveAlbum } from '@/features/offline';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react';
import AlbumCard from '@/features/album/components/AlbumCard';
import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '@/cover/layoutSizes';
import { useLibraryCoverPrefetch } from '@/cover/useLibraryCoverPrefetch';
import { useAuthStore } from '@/store/authStore';
import { clampLibraryGridMaxColumns } from '@/store/authStoreHelpers';
import { computeCardGridColumnCount } from '@/utils/cardGridLayout';
import GenreFilterBar from '@/components/GenreFilterBar';
import YearFilterButton from '@/components/YearFilterButton';
import StarFilterButton from '@/components/StarFilterButton';
import LosslessFilterButton from '@/components/LosslessFilterButton';
import SortDropdown from '@/components/SortDropdown';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useOfflineStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import { usePlayerStore } from '@/store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '@/utils/ui/toast';
import { useZipDownloadStore } from '@/features/offline';
import { Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react';
import SelectionToggleButton from '@/components/SelectionToggleButton';
import FilterQuickClear from '@/components/FilterQuickClear';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { useRangeSelection } from '@/hooks/useRangeSelection';
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
import InpageScrollSentinel from '@/components/InpageScrollSentinel';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { useAlbumBrowseFilters, useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
import { useAlbumBrowseData } from '@/features/album/hooks/useAlbumBrowseData';
import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrowseScrollRestore';
import { useAlbumBrowseScrollReset } from '@/features/album/hooks/useAlbumBrowseScrollReset';
import { useBrowseAlbumTextSearch } from '@/features/album/hooks/useBrowseAlbumTextSearch';
import { peekAlbumBrowseScrollRestore } from '@/features/album/store/albumBrowseSessionStore';
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { useAlbumCatalogYearBounds } from '@/features/album/hooks/useAlbumCatalogYearBounds';
import type { AlbumBrowseSort } from '@/utils/library/albumBrowseSort';
import { LOSSLESS_MODE_QUERY } from '@/utils/library/losslessMode';
import { resolveAlbumYearBounds } from '@/utils/library/albumYearFilter';
import {
filterAlbumsByCompilation,
filterAlbumsByGenres,
filterAlbumsByStarred,
filterAlbumsByYearBounds,
} from '@/utils/library/albumBrowseFilters';
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
type SortType = AlbumBrowseSort;
function sanitizeFilename(name: string): string {
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safe download filenames
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
export default function Albums() {
const perfFlags = usePerfProbeFlags();
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const catalogYears = useAlbumCatalogYearBounds(serverId, indexEnabled, musicLibraryFilterVersion);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
const restoreDisplayCountRef = useRef<number | undefined>(
peekAlbumBrowseScrollRestore(serverId, 'albums')?.displayCount,
);
const {
sort,
onSortChange,
selectedGenres,
setSelectedGenres,
yearFrom,
setYearFrom,
yearTo,
setYearTo,
compFilter,
setCompFilter,
starredOnly,
setStarredOnly,
losslessOnly,
setLosslessOnly,
} = useAlbumBrowseFilters(serverId, scrollSnapshotRef);
const albumsSearchQuery = useScopedBrowseSearchQuery('albums');
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
albumsSearchQuery,
indexEnabled,
serverId,
losslessOnly,
);
const {
scrollBodyEl,
bindScrollBody: bindAlbumsScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const browseData = useAlbumBrowseData({
serverId,
indexEnabled,
musicLibraryFilterVersion,
sort,
selectedGenres,
yearFrom,
yearTo,
losslessOnly,
starredOnly,
compFilter,
starredOverrides,
getScrollRoot,
scrollRootEl: scrollBodyEl,
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
// eslint-disable-next-line react-hooks/refs
restoreDisplayCount: restoreDisplayCountRef.current,
});
const textSearchActive = textSearchAlbums != null;
const albumBrowsePlainLayout =
perfFlags.disableMainstageVirtualLists
|| textSearchActive
|| albumsSearchQuery.trim().length > 0;
const textSearchYearBounds = useMemo(
() => resolveAlbumYearBounds(browseData.debouncedYearFields.from, browseData.debouncedYearFields.to),
[browseData.debouncedYearFields.from, browseData.debouncedYearFields.to],
);
const textSearchVisibleAlbums = useMemo(() => {
if (!textSearchActive || !textSearchAlbums) return null;
let out = textSearchAlbums;
if (selectedGenres.length > 0) out = filterAlbumsByGenres(out, selectedGenres);
if (textSearchYearBounds.active) out = filterAlbumsByYearBounds(out, textSearchYearBounds.bounds);
if (compFilter !== 'all') out = filterAlbumsByCompilation(out, compFilter);
if (starredOnly) out = filterAlbumsByStarred(out, starredOverrides);
return out;
}, [
textSearchActive,
textSearchAlbums,
selectedGenres,
textSearchYearBounds.active,
textSearchYearBounds.bounds,
compFilter,
starredOnly,
starredOverrides,
]);
const albums = textSearchActive ? (textSearchAlbums ?? []) : browseData.albums;
const loading = textSearchActive ? textSearchLoading : browseData.loading;
const loadingMore = textSearchActive ? false : browseData.loadingMore;
const hasMore = textSearchActive ? false : browseData.hasMore;
const displayAlbums = useMemo(
() => (textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.displayAlbums),
[textSearchActive, textSearchVisibleAlbums, browseData.displayAlbums],
);
const visibleAlbums = textSearchActive ? (textSearchVisibleAlbums ?? []) : browseData.visibleAlbums;
const genreFiltered = textSearchActive ? selectedGenres.length > 0 : browseData.genreFiltered;
const serverFilterActive = textSearchActive
? selectedGenres.length > 0 || textSearchYearBounds.active || losslessOnly || starredOnly
: browseData.serverFilterActive;
const yearFilterActive = browseData.yearFilterActive;
const debouncedYearFields = browseData.debouncedYearFields;
const compFilterActive = browseData.compFilterActive;
const pendingClientFilterMatch = textSearchActive ? false : browseData.pendingClientFilterMatch;
const bindLoadMoreSentinel = browseData.bindLoadMoreSentinel;
const loadMore = browseData.loadMore;
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
serverId,
surface: 'albums',
scrollBodyEl,
displayAlbumsLength: displayAlbums.length,
loading,
loadingMore,
hasMore,
loadMore,
});
useAlbumBrowseScrollReset({
scrollSnapshotRef,
getScrollRoot,
isScrollRestorePending,
resetKey: [
albumsSearchQuery,
sort,
selectedGenres.join('\u0001'),
yearFilterActive ? `${debouncedYearFields.from}:${debouncedYearFields.to}` : '',
compFilter,
starredOnly,
losslessOnly,
serverId,
].join('|'),
});
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return;
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
const gridMeasureRef = useRef<HTMLDivElement>(null);
const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns));
const [albumCellDisplayCssPx, setAlbumCellDisplayCssPx] = useState(140);
const [albumGridCols, setAlbumGridCols] = useState(4);
// ── Multi-selection ──────────────────────────────────────────────────────
// `displayAlbums` — visible grid slice (local index) or loaded SQL pages (network).
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
resetSelection();
};
const clearSelection = () => {
setSelectionMode(false);
resetSelection();
};
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const enqueue = usePlayerStore(state => state.enqueue);
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
try {
// Parallel album resolves — Navidrome handles concurrent requests fine.
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
enqueue(tracks);
showToast(t('albums.enqueueQueued', { count: selectedAlbums.length }), 2500, 'info');
}
} finally {
clearSelection();
}
};
const cycleCompFilter = () => {
setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all');
};
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const { start, complete, fail } = useZipDownloadStore.getState();
clearSelection();
for (const album of selectedAlbums) {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const visibleEmptyMessage = useMemo(() => {
if (starredOnly) return t('albums.noFavorites');
if (compFilter === 'only') return t('albums.noCompilations');
return t('albums.noMatchingFilters');
}, [starredOnly, compFilter, t]);
useLayoutEffect(() => {
const el = gridMeasureRef.current;
if (!el) return;
const update = () => {
const w = el.clientWidth;
const cols = computeCardGridColumnCount(w, maxGridCols);
setAlbumGridCols(cols);
setAlbumCellDisplayCssPx(coverDisplayCssPxForAlbumGrid(w, maxGridCols));
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [maxGridCols, displayAlbums.length]);
const prefetchLimit = Math.max(albumGridCols * 3, albumGridCols);
const prefetchKey = useMemo(
() => displayAlbums.slice(0, prefetchLimit).map(a => a.id).join('\u0001'),
[displayAlbums, prefetchLimit],
);
const prefetchAlbums = useMemo(
() => displayAlbums.slice(0, prefetchLimit),
[displayAlbums, prefetchLimit],
);
useLibraryCoverPrefetch(
[
{
albums: prefetchAlbums,
priority: 'high',
},
],
[prefetchKey, albumGridCols],
);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
albumsSearchQuery,
sort,
genreFiltered,
yearFilterActive,
debouncedYearFields.from,
debouncedYearFields.to,
compFilter,
starredOnly,
losslessOnly,
selectionMode,
selectedGenres,
]);
useEffect(() => {
if (!indexEnabled && losslessOnly) setLosslessOnly(false);
}, [indexEnabled, losslessOnly, setLosslessOnly]);
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
{ value: 'byArtistThenYear', label: t('albums.sortByArtistYear') },
];
return (
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
{!perfFlags.disableMainstageStickyHeader && (
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('albums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected} data-tooltip={t('albums.enqueueSelected', { count: selectedIds.size })} data-tooltip-pos="bottom">
<ListPlus size={15} />
<span className="toolbar-btn-label">{t('albums.enqueueSelected', { count: selectedIds.size })}</span>
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline} data-tooltip={t('albums.addOffline')} data-tooltip-pos="bottom">
<HardDriveDownload size={15} />
<span className="toolbar-btn-label">{t('albums.addOffline')}</span>
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips} data-tooltip={t('albums.downloadZips')} data-tooltip-pos="bottom">
<Download size={15} />
<span className="toolbar-btn-label">{t('albums.downloadZips')}</span>
</button>
</>
) : (
<>
<SortDropdown
value={sort}
options={sortOptions}
onChange={onSortChange}
tooltip={t('albums.sortTooltip')}
/>
<YearFilterButton
from={yearFrom}
to={yearTo}
catalogMinYear={catalogYears.min}
catalogMaxYear={catalogYears.max}
onChange={(from, to) => { setYearFrom(from); setYearTo(to); }}
/>
<GenreFilterBar
selected={selectedGenres}
catalogGenres={browseData.genreCatalogActive ? browseData.genreCatalogOptions : null}
onSelectionChange={setSelectedGenres}
/>
<StarFilterButton active={starredOnly} onChange={setStarredOnly} />
{indexEnabled && (
<LosslessFilterButton active={losslessOnly} onChange={setLosslessOnly} />
)}
<button
className={`btn btn-surface${compFilter !== 'all' ? ' btn-sort-active' : ''}`}
onClick={cycleCompFilter}
data-tooltip={
compFilter === 'all' ? t('albums.compilationTooltipAll')
: compFilter === 'only' ? t('albums.compilationTooltipOnly')
: t('albums.compilationTooltipHide')
}
data-tooltip-pos="bottom"
style={{
display: 'flex', alignItems: 'center', gap: '0.4rem',
...(compFilter !== 'all' ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}),
}}
>
<Disc3 size={14} />
<span className="toolbar-btn-label">
{compFilter === 'all' ? t('albums.compilationLabel')
: compFilter === 'only' ? t('albums.compilationOnly')
: t('albums.compilationHide')}
</span>
{compFilter !== 'all' && (
<FilterQuickClear onActiveChip onClear={() => setCompFilter('all')} />
)}
</button>
</>
)}
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
)}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindAlbumsScrollBody}
railInset="panel"
measureDeps={[
loading,
displayAlbums.length,
genreFiltered,
hasMore,
selectionMode,
sort,
albumsSearchQuery,
perfFlags.disableMainstageGridCards,
albumBrowsePlainLayout,
]}
>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 && !serverFilterActive && !compFilterActive ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : !loading && albums.length === 0 && losslessOnly ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('losslessAlbums.empty')}
</div>
) : !loading && visibleAlbums.length === 0 && pendingClientFilterMatch ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && visibleAlbums.length === 0 && (starredOnly || compFilterActive) ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{visibleEmptyMessage}
</div>
) : !loading && textSearchActive && visibleAlbums.length === 0 ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('albums.noMatchingFilters')}
</div>
) : (
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
{!perfFlags.disableMainstageGridCards && (
<div ref={gridMeasureRef}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={albumBrowsePlainLayout}
layoutSignal={displayAlbums.length}
scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers(
albumCellDisplayCssPx,
Math.min(displayAlbums.length, Math.max(albumGridCols * 6, 48)),
)}
renderItem={a => (
<AlbumCard
album={a}
displayCssPx={albumCellDisplayCssPx}
observeScrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={losslessOnly ? LOSSLESS_MODE_QUERY : undefined}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
</div>
)}
{hasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
)}
</div>
{isScrollRestorePending && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
justifyContent: 'center',
paddingTop: '3rem',
background: 'var(--bg-app)',
}}
>
<div className="spinner" />
</div>
)}
</div>
)}
</OverlayScrollArea>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { search } from '@/api/subsonicSearch';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import AlbumCard from '@/features/album/components/AlbumCard';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
export default function LabelAlbums() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => {
if (!name) return;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
// Search for the label name and ask for a large number of albums
search(name, { albumCount: 200, artistCount: 0, songCount: 0 })
.then(res => {
// Filter out albums that don't match the record label exactly if possible,
// to avoid unrelated search hits. We do case-insensitive comparison.
const matches = res.albums.filter(a =>
a.recordLabel?.toLowerCase() === name.toLowerCase()
);
// Fallback: if Navidrome's search doesn't return the exact label in the recordLabel field
// (or it's not indexed exactly as typed), just show all album matches
// as a decent best-effort if our strict filter yields nothing.
setAlbums(matches.length > 0 ? matches : res.albums);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [name, musicLibraryFilterVersion]);
return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ margin: '1rem 0', gap: '6px' }}>
<ChevronLeft size={16} /> {t('common.back')}
</button>
<h1 className="page-title" style={{ marginBottom: '2rem' }}>
Label: <span style={{ color: 'var(--accent)' }}>{name}</span>
</h1>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : albums.length === 0 ? (
<div className="empty-state">{t('common.noAlbums')}</div>
) : (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
warmGridCovers={albumGridWarmCovers()}
renderItem={a => <AlbumCard album={a} />}
/>
)}
</div>
);
}
+405
View File
@@ -0,0 +1,405 @@
import { buildDownloadUrl } from '@/api/subsonicStreamUrl';
import { resolveAlbum } from '@/features/offline';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { songToTrack } from '@/utils/playback/songToTrack';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AlbumCard from '@/features/album/components/AlbumCard';
import { LOSSLESS_MODE_QUERY } from '@/utils/library/losslessMode';
import { ndListLosslessAlbumsPage } from '@/api/navidromeBrowse';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import { useOfflineStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import { usePlayerStore } from '@/store/playerStore';
import { useZipDownloadStore } from '@/features/offline';
import { useRangeSelection } from '@/hooks/useRangeSelection';
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { showToast } from '@/utils/ui/toast';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { Download, HardDriveDownload, ListPlus } from 'lucide-react';
import SelectionToggleButton from '@/components/SelectionToggleButton';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useInpageScrollSentinel } from '@/hooks/useInpageScrollSentinel';
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
import InpageScrollSentinel from '@/components/InpageScrollSentinel';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import SortDropdown from '@/components/SortDropdown';
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import {
albumBrowseSortForServer,
useAlbumBrowseSessionStore,
} from '@/features/album/store/albumBrowseSessionStore';
import {
runLocalAlbumBrowsePage,
sortSubsonicAlbums,
type AlbumBrowseSort,
} from '@/utils/library/browseTextSearch';
/** Local index page size — SQLite is cheap; larger pages than the network walk. */
const LOCAL_PAGE_SIZE = 30;
/** Per-loadMore budget for the Navidrome bit_depth song-stream fallback. */
const NETWORK_TARGET_ALBUMS = 12;
const NETWORK_SONGS_PER_FETCH = 100;
const NETWORK_MAX_FETCHES_PER_LOAD = 2;
function sanitizeFilename(name: string): string {
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safe download filenames
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
export default function LosslessAlbums() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const auth = useAuthStore();
const activeServerId = useAuthStore(s => s.activeServerId);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
const setBrowseSort = useAlbumBrowseSessionStore(s => s.setSort);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const enqueue = usePlayerStore(s => s.enqueue);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [unsupported, setUnsupported] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
/** `true` = local SQLite; `false` = Navidrome song-stream walk; `null` until first fetch picks. */
const [useLocalIndex, setUseLocalIndex] = useState<boolean | null>(null);
const displayAlbums = useMemo(() => {
if (useLocalIndex === false) return sortSubsonicAlbums(albums, sort);
return albums;
}, [albums, sort, useLocalIndex]);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
const selectedAlbums = displayAlbums.filter(a => selectedIds.has(a.id));
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
/** Network pagination cursor — unused on the local path. */
const songCursor = useRef(0);
const seenIds = useRef<Set<string>>(new Set());
const localOffset = useRef(0);
const inFlight = useRef(false);
const {
scrollBodyEl,
bindScrollBody: bindLosslessScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const sortOptions: { value: AlbumBrowseSort; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
unsupported,
selectionMode,
activeServerId,
sort,
]);
const loadMoreNetwork = useCallback(async (onProgress?: (albums: SubsonicAlbum[]) => void) => {
const page = await ndListLosslessAlbumsPage({
startSongOffset: songCursor.current,
seenAlbumIds: seenIds.current,
targetNewAlbums: NETWORK_TARGET_ALBUMS,
songsPerPage: NETWORK_SONGS_PER_FETCH,
maxPagesPerCall: NETWORK_MAX_FETCHES_PER_LOAD,
onProgress: onProgress
? (entries) => { onProgress(entries.map(e => e.album)); }
: undefined,
});
songCursor.current = page.nextSongOffset;
return page;
}, []);
const loadMoreLocal = useCallback(async () => {
const data = await runLocalAlbumBrowsePage(
serverId,
sort,
localOffset.current,
LOCAL_PAGE_SIZE,
undefined,
true,
);
if (data == null) return null;
localOffset.current += data.length;
return { albums: data, hasMore: data.length === LOCAL_PAGE_SIZE };
}, [serverId, sort]);
const loadMore = useCallback(async () => {
if (inFlight.current || useLocalIndex === null) return;
inFlight.current = true;
setLoading(true);
try {
if (useLocalIndex) {
const page = await loadMoreLocal();
if (!page) {
setHasMore(false);
return;
}
setAlbums(prev => [...prev, ...page.albums]);
setHasMore(page.hasMore);
} else {
const page = await loadMoreNetwork(albums => {
setAlbums(prev => [...prev, ...albums]);
});
setHasMore(!page.done);
}
} catch {
if (!useLocalIndex) {
setUnsupported(true);
}
setHasMore(false);
} finally {
inFlight.current = false;
setLoading(false);
}
}, [loadMoreLocal, loadMoreNetwork, useLocalIndex]);
useEffect(() => {
let cancelled = false;
songCursor.current = 0;
seenIds.current = new Set();
localOffset.current = 0;
inFlight.current = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setAlbums([]);
setHasMore(true);
setUnsupported(false);
setUseLocalIndex(null);
setLoading(true);
(async () => {
inFlight.current = true;
try {
if (indexEnabled && serverId) {
const data = await runLocalAlbumBrowsePage(
serverId,
sort,
0,
LOCAL_PAGE_SIZE,
undefined,
true,
);
if (cancelled) return;
if (data != null) {
setUseLocalIndex(true);
localOffset.current = data.length;
setAlbums(data);
setHasMore(data.length === LOCAL_PAGE_SIZE);
return;
}
}
if (cancelled) return;
setUseLocalIndex(false);
const page = await loadMoreNetwork(albums => {
if (!cancelled) setAlbums(prev => [...prev, ...albums]);
});
if (cancelled) return;
songCursor.current = page.nextSongOffset;
setHasMore(!page.done);
} catch {
if (cancelled) return;
setUseLocalIndex(false);
setUnsupported(true);
setHasMore(false);
} finally {
inFlight.current = false;
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [activeServerId, indexEnabled, loadMoreNetwork, serverId, sort]);
const bindLoadMoreSentinel = useInpageScrollSentinel({
active: hasMore && useLocalIndex !== null,
getScrollRoot,
scrollRootEl: scrollBodyEl,
onIntersect: () => { void loadMore(); },
rootMargin: '200px',
});
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
try {
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
enqueue(tracks);
showToast(t('albums.enqueueQueued', { count: selectedAlbums.length }), 2500, 'info');
}
} finally {
clearSelection();
}
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const { start, complete, fail } = useZipDownloadStore.getState();
clearSelection();
for (const album of selectedAlbums) {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
};
return (
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
{!perfFlags.disableMainstageStickyHeader && (
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.15rem', minWidth: 0 }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('home.losslessAlbums')}
</h1>
{!(selectionMode && selectedIds.size > 0) && useLocalIndex === false && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: 1.3 }}>
{t('losslessAlbums.slowFetchHint')}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{!(selectionMode && selectedIds.size > 0) && (
<SortDropdown
value={sort}
options={sortOptions}
onChange={value => setBrowseSort(serverId, value)}
/>
)}
{selectionMode && selectedIds.size > 0 && (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
<ListPlus size={15} />
{t('albums.enqueueSelected', { count: selectedIds.size })}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<Download size={15} />
{t('albums.downloadZips')}
</button>
</>
)}
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
)}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindLosslessScrollBody}
railInset="panel"
measureDeps={[
unsupported,
loading,
albums.length,
hasMore,
selectionMode,
useLocalIndex,
perfFlags.disableMainstageVirtualLists,
perfFlags.disableMainstageStickyHeader,
]}
>
{unsupported ? (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
{t('losslessAlbums.unsupported')}
</div>
) : loading && displayAlbums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : displayAlbums.length === 0 ? (
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
{t('losslessAlbums.empty')}
</div>
) : (
<>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayAlbums.length}
scrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers()}
renderItem={a => (
<AlbumCard
album={a}
observeScrollRootId={LOSSLESS_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
linkQuery={LOSSLESS_MODE_QUERY}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
)}
/>
{hasMore && useLocalIndex !== null && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loading} />
)}
</>
)}
</OverlayScrollArea>
</div>
);
}
+418
View File
@@ -0,0 +1,418 @@
import { buildDownloadUrl } from '@/api/subsonicStreamUrl';
import { getAlbumsByGenre } from '@/api/subsonicGenres';
import { getAlbumList } from '@/api/subsonicLibrary';
import { resolveAlbum } from '@/features/offline';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { dedupeById } from '@/utils/dedupeById';
import { shuffleArray } from '@/utils/playback/shuffleArray';
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, Download, HardDriveDownload } from 'lucide-react';
import SelectionToggleButton from '@/components/SelectionToggleButton';
import AlbumCard from '@/features/album/components/AlbumCard';
import GenreFilterBar from '@/components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
import { runLocalRandomAlbums, runLocalAlbumsByGenres } from '@/utils/library/browseTextSearch';
import { useOfflineStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '@/utils/ui/toast';
import { useZipDownloadStore } from '@/features/offline';
import { useRangeSelection } from '@/hooks/useRangeSelection';
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
import {
primeAlbumCoversForDisplay,
} from '@/cover/warmDiskPeek';
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
import OverlayScrollArea from '@/ui/OverlayScrollArea';
import { RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '@/features/album/hooks/useAlbumGridBrowseFilters';
import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrowseScrollRestore';
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
const ALBUM_FETCH_OVERSHOOT = 100;
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
const GENRE_UNION_PREFILTER_CAP = 250;
function sanitizeFilename(name: string): string {
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safe download filenames
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
}
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const pool = shuffleArray(dedupeById(results.flat())).slice(0, GENRE_UNION_PREFILTER_CAP);
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
return filtered.slice(0, ALBUM_COUNT);
}
/** Shared fetch logic — used by both `load` and the background reserve fill. */
async function doFetchRandomAlbums(genres: string[]): Promise<SubsonicAlbum[]> {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMixActive = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
const serverId = useAuthStore.getState().activeServerId ?? '';
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
if (genres.length === 0 && indexEnabled && serverId) {
// Local path: SQLite ORDER BY RANDOM() LIMIT N — no network, effectively instant.
const local = await runLocalRandomAlbums(serverId, randomSize);
if (local && local.length > 0) {
return (await filterAlbumsByMixRatings(local, mixCfg)).slice(0, ALBUM_COUNT);
}
}
if (genres.length > 0 && indexEnabled && serverId) {
// Genre path: local index union + JS shuffle (avoids per-genre network requests).
const allLocal = await runLocalAlbumsByGenres(serverId, genres, 'alphabeticalByName', GENRE_UNION_PREFILTER_CAP);
if (allLocal && allLocal.length > 0) {
const pool = shuffleArray(dedupeById(allLocal)).slice(0, GENRE_UNION_PREFILTER_CAP);
return (await filterAlbumsByMixRatings(pool, mixCfg)).slice(0, ALBUM_COUNT);
}
}
// Network fallback when local index is unavailable or returned nothing.
return genres.length > 0
? fetchByGenres(genres)
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
}
// ── Module-level reserve: next batch pre-fetched after each Refresh ──────────
type AlbumReserve = { filterId: string; albums: SubsonicAlbum[] };
let _nextReserve: AlbumReserve | null = null;
let _reserveFilling = false;
function makeFilterId(
libraryVersion: number,
mixEnabled: boolean,
minAlbum: number,
minArtist: number,
genres: string[],
): string {
return `${libraryVersion}:${mixEnabled}:${minAlbum}:${minArtist}:${genres.join('\x01')}`;
}
/** Consume the pre-fetched reserve if the filter matches, otherwise discard it. */
function takeReserve(filterId: string): SubsonicAlbum[] | null {
if (_nextReserve?.filterId === filterId) {
const albums = _nextReserve.albums;
_nextReserve = null;
return albums;
}
_nextReserve = null;
return null;
}
/**
* Fire-and-forget: fetch the next batch in the background so it's ready for
* the next Refresh. Covers are NOT pre-warmed here doing so would call
* bumpDiskSrcCache() for every reserve cover, which re-renders all useCoverArt
* subscribers on the current page and causes a visible flash ~1.5 s after load.
* Covers are warmed lazily via primeAlbumCoversForDisplay when the reserve is
* actually consumed.
*/
async function fillReserve(filterId: string, genres: string[]): Promise<void> {
if (_reserveFilling) return;
_reserveFilling = true;
try {
const albums = await doFetchRandomAlbums(genres);
_nextReserve = { filterId, albums };
} catch {
// Network or cache failure — next Refresh falls back to a fresh fetch.
} finally {
_reserveFilling = false;
}
}
export default function RandomAlbums() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const auth = useAuthStore();
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
const mixMinRatingArtist = auth.mixMinRatingArtist;
const serverId = auth.activeServerId ?? '';
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
const location = useLocation();
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
const gridSnapshotRef = useRef<AlbumGridBrowseSnapshot>({ albums: [], hasMore: false });
const {
selectedGenres,
setSelectedGenres,
initialAlbums,
} = useAlbumGridBrowseFilters(serverId, 'random-albums', scrollSnapshotRef, gridSnapshotRef);
const restoringSessionRef = useRef(initialAlbums != null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
const [loading, setLoading] = useState(() => initialAlbums == null);
const loadingRef = useRef(false);
const filtered = selectedGenres.length > 0;
const {
scrollBodyEl,
bindScrollBody: bindRandomAlbumsScrollBody,
} = useInpageScrollViewport();
const [selectionMode, setSelectionMode] = useState(false);
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const { start, complete, fail } = useZipDownloadStore.getState();
clearSelection();
for (const album of selectedAlbums) {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
};
const handleAddOffline = async () => {
if (selectedAlbums.length === 0) return;
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
}
}
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
clearSelection();
};
const load = useCallback(async (genres: string[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const filterId = makeFilterId(
musicLibraryFilterVersion, mixMinRatingFilterEnabled,
mixMinRatingAlbum, mixMinRatingArtist, genres,
);
const reserved = takeReserve(filterId);
if (reserved) {
await primeAlbumCoversForDisplay(reserved, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
setAlbums(reserved);
} else {
const data = await doFetchRandomAlbums(genres);
await primeAlbumCoversForDisplay(data, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
setAlbums(data);
}
// Pre-fetch + disk-warm the next batch so the next Refresh is instant.
void fillReserve(filterId, genres);
} catch (e) {
console.error(e);
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
const loadRef = useRef(load);
// 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
loadRef.current = load;
useEffect(() => {
if (restoringSessionRef.current) return;
loadRef.current(selectedGenres);
}, [selectedGenres]);
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
const handleRefresh = useCallback(() => {
if (scrollBodyEl) {
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
// eslint-disable-next-line react-hooks/immutability
scrollBodyEl.scrollTop = 0;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
}
scrollSnapshotRef.current.scrollTop = 0;
load(selectedGenres);
}, [scrollBodyEl, load, selectedGenres]);
// 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
gridSnapshotRef.current = { albums, hasMore: false };
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
serverId,
surface: 'random-albums',
scrollBodyEl,
displayAlbumsLength: albums.length,
loading,
loadingMore: false,
hasMore: false,
loadMore: () => {},
});
useLayoutEffect(() => {
if (!isScrollRestorePending && restoringSessionRef.current) {
restoringSessionRef.current = false;
}
}, [isScrollRestorePending]);
useEffect(() => {
if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return;
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
filtered,
selectionMode,
selectedGenres,
]);
return (
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('albums.selectionCount', { count: selectedIds.size })
: t('randomAlbums.title')}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline} aria-label={t('albums.addOffline')} data-tooltip={t('albums.addOffline')}>
<HardDriveDownload size={15} />
<span className="toolbar-btn-label">{t('albums.addOffline')}</span>
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips} aria-label={t('albums.downloadZips')} data-tooltip={t('albums.downloadZips')}>
<Download size={15} />
<span className="toolbar-btn-label">{t('albums.downloadZips')}</span>
</button>
</>
) : (
<>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
<button
className="btn btn-surface"
onClick={handleRefresh}
disabled={loading}
aria-label={t('randomAlbums.refresh')}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
<span className="toolbar-btn-label">{t('randomAlbums.refresh')}</span>
</button>
</>
)}
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindRandomAlbumsScrollBody}
railInset="panel"
measureDeps={[
loading,
albums.length,
filtered,
selectionMode,
perfFlags.disableMainstageVirtualLists,
]}
>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && albums.length === 0 ? (
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('common.libraryEmpty')}
</div>
) : (
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
scrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers()}
renderItem={a => (
<AlbumCard
album={a}
observeScrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
ensurePriority="high"
/>
)}
/>
</div>
{isScrollRestorePending && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
justifyContent: 'center',
paddingTop: '3rem',
background: 'var(--bg-app)',
}}
>
<div className="spinner" />
</div>
)}
</div>
)}
</OverlayScrollArea>
</div>
);
}
@@ -0,0 +1,158 @@
import { describe, expect, it, beforeEach } from 'vitest';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import {
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
DEFAULT_ALBUM_BROWSE_SORT,
albumBrowseSortForServer,
albumBrowseSurfaceForPath,
clearGenreDetailReturnStash,
isAlbumDetailPath,
isAlbumsBrowsePath,
isNewReleasesBrowsePath,
isGenreDetailPath,
genreDetailGenreFromPath,
peekAlbumBrowseScrollRestore,
peekGenreDetailReturnStash,
peekGenreDetailScrollRestore,
stashGenreDetailReturnFilters,
useAlbumBrowseSessionStore,
} from '@/features/album/store/albumBrowseSessionStore';
describe('albumBrowseSessionStore', () => {
beforeEach(() => {
useAlbumBrowseSessionStore.setState({ sortByServer: {}, returnStashByKey: {} });
});
it('keeps sort per server for the session', () => {
const { setSort } = useAlbumBrowseSessionStore.getState();
setSort('srv-a', 'alphabeticalByArtist');
setSort('srv-b', 'alphabeticalByName');
const { sortByServer } = useAlbumBrowseSessionStore.getState();
expect(albumBrowseSortForServer(sortByServer, 'srv-a')).toBe('alphabeticalByArtist');
expect(albumBrowseSortForServer(sortByServer, 'srv-b')).toBe('alphabeticalByName');
});
it('stashes and peeks return filters with scroll snapshot per surface', () => {
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', 'albums', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Rock'],
yearFrom: '1990',
yearTo: '2000',
starredOnly: true,
scrollTop: 840,
displayCount: 120,
});
expect(peekReturnStash('srv-a', 'albums')).toEqual({
selectedGenres: ['Rock'],
yearFrom: '1990',
yearTo: '2000',
compFilter: 'all',
starredOnly: true,
losslessOnly: false,
scrollTop: 840,
displayCount: 120,
});
expect(peekReturnStash('srv-a', 'new-releases')).toBeNull();
});
it('peeks scroll restore target for a surface', () => {
const { stashReturnFilters } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', 'new-releases', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
scrollTop: 420,
displayCount: 180,
});
expect(peekAlbumBrowseScrollRestore('srv-a', 'new-releases')).toEqual({
scrollTop: 420,
displayCount: 180,
});
expect(peekAlbumBrowseScrollRestore('srv-b', 'new-releases')).toBeNull();
});
it('stashes cached album rows for grid surfaces', () => {
const albums = [{ id: 'a1', name: 'A', artist: 'X', artistId: 'x' }] as SubsonicAlbum[];
const { stashReturnFilters, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', 'random-albums', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Jazz'],
albums,
hasMore: false,
scrollTop: 100,
displayCount: 1,
});
expect(peekReturnStash('srv-a', 'random-albums')?.albums).toEqual(albums);
});
it('clears return stash for a surface only', () => {
const { stashReturnFilters, clearReturnStash, peekReturnStash } = useAlbumBrowseSessionStore.getState();
stashReturnFilters('srv-a', 'albums', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Jazz'],
});
stashReturnFilters('srv-a', 'new-releases', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Rock'],
});
clearReturnStash('srv-a', 'albums');
expect(peekReturnStash('srv-a', 'albums')).toBeNull();
expect(peekReturnStash('srv-a', 'new-releases')?.selectedGenres).toEqual(['Rock']);
});
it('defaults sort when server has no entry', () => {
const { sortByServer } = useAlbumBrowseSessionStore.getState();
expect(albumBrowseSortForServer(sortByServer, 'unknown')).toBe(DEFAULT_ALBUM_BROWSE_SORT);
});
it('stashes genre detail leave snapshot separately from album grid surfaces', () => {
stashGenreDetailReturnFilters('srv-a', 'Rock', {
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
selectedGenres: ['Rock'],
scrollTop: 640,
displayCount: 90,
});
expect(peekGenreDetailReturnStash('srv-a', 'Rock')?.scrollTop).toBe(640);
expect(peekGenreDetailScrollRestore('srv-a', 'Rock')).toEqual({
scrollTop: 640,
displayCount: 90,
});
clearGenreDetailReturnStash('srv-a', 'Rock');
expect(peekGenreDetailReturnStash('srv-a', 'Rock')).toBeNull();
});
});
describe('isAlbumDetailPath', () => {
it('matches album detail routes only', () => {
expect(isAlbumDetailPath('/album/abc')).toBe(true);
expect(isAlbumDetailPath('/album/abc/')).toBe(true);
expect(isAlbumDetailPath('/albums')).toBe(false);
expect(isAlbumDetailPath('/artist/abc')).toBe(false);
expect(isAlbumDetailPath('/album/abc/tracks')).toBe(false);
});
});
describe('isGenreDetailPath', () => {
it('matches single genre detail routes only', () => {
expect(isGenreDetailPath('/genres/Rock')).toBe(true);
expect(isGenreDetailPath('/genres/Rock%20%26%20Roll')).toBe(true);
expect(isGenreDetailPath('/genres')).toBe(false);
expect(isGenreDetailPath('/genres/Rock/albums')).toBe(false);
expect(genreDetailGenreFromPath('/genres/Rock%20%26%20Roll')).toBe('Rock & Roll');
});
});
describe('albumBrowseSurfaceForPath', () => {
it('maps album grid browse routes', () => {
expect(albumBrowseSurfaceForPath('/albums')).toBe('albums');
expect(isAlbumsBrowsePath('/albums')).toBe(true);
expect(isAlbumsBrowsePath('/albums/')).toBe(true);
expect(isAlbumsBrowsePath('/new-releases')).toBe(false);
expect(isNewReleasesBrowsePath('/new-releases')).toBe(true);
expect(isNewReleasesBrowsePath('/new-releases/')).toBe(true);
expect(albumBrowseSurfaceForPath('/new-releases')).toBe('new-releases');
expect(albumBrowseSurfaceForPath('/random/albums')).toBe('random-albums');
expect(albumBrowseSurfaceForPath('/artists')).toBeNull();
});
});
@@ -0,0 +1,244 @@
import { create } from 'zustand';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import type { AlbumBrowseSort } from '@/utils/library/browseTextSearch';
export const DEFAULT_ALBUM_BROWSE_SORT: AlbumBrowseSort = 'alphabeticalByName';
export type AlbumBrowseCompFilter = 'all' | 'only' | 'hide';
/** Album grid browse surfaces that share leave-restore session behavior. */
export type AlbumBrowseSurface = 'albums' | 'new-releases' | 'random-albums';
/** Browse state restored when returning via browser/app back from album detail. */
export interface AlbumBrowseReturnFilters {
selectedGenres: string[];
yearFrom: string;
yearTo: string;
compFilter: AlbumBrowseCompFilter;
starredOnly: boolean;
losslessOnly: boolean;
/** Header live search query when leaving for album detail (All Albums scope). */
searchQuery?: string;
/** In-page grid scroll position when leaving the browse surface. */
scrollTop?: number;
/** Row count at leave time — preload at least this many rows before scroll. */
displayCount?: number;
/** Cached grid rows (New Releases / Random Albums). */
albums?: SubsonicAlbum[];
hasMore?: boolean;
}
export const DEFAULT_ALBUM_BROWSE_RETURN_FILTERS: AlbumBrowseReturnFilters = {
selectedGenres: [],
yearFrom: '',
yearTo: '',
compFilter: 'all',
starredOnly: false,
losslessOnly: false,
};
interface AlbumBrowseSessionStore {
/** Session-lifetime sort per server (sidebar ↔ album detail). */
sortByServer: Record<string, AlbumBrowseSort>;
/** Stashed when leaving a browse surface → album detail; consumed after scroll restore. */
returnStashByKey: Record<string, AlbumBrowseReturnFilters>;
setSort: (serverId: string, sort: AlbumBrowseSort) => void;
stashReturnFilters: (
serverId: string,
surface: AlbumBrowseSurface,
filters: AlbumBrowseReturnFilters,
) => void;
clearReturnStash: (serverId: string, surface: AlbumBrowseSurface) => void;
peekReturnStash: (serverId: string, surface: AlbumBrowseSurface) => AlbumBrowseReturnFilters | null;
}
function returnStashKey(serverId: string, surface: AlbumBrowseSurface): string {
return `${serverId}:${surface}`;
}
function genreDetailStashKey(serverId: string, genreName: string): string {
return `${serverId}:genre-detail:${genreName}`;
}
function sortEntryFor(
sortByServer: Record<string, AlbumBrowseSort>,
serverId: string,
): AlbumBrowseSort {
return sortByServer[serverId] ?? DEFAULT_ALBUM_BROWSE_SORT;
}
function cloneReturnFilters(filters: AlbumBrowseReturnFilters): AlbumBrowseReturnFilters {
return {
selectedGenres: [...filters.selectedGenres],
yearFrom: filters.yearFrom,
yearTo: filters.yearTo,
compFilter: filters.compFilter,
starredOnly: filters.starredOnly,
losslessOnly: filters.losslessOnly,
...(typeof filters.searchQuery === 'string' ? { searchQuery: filters.searchQuery } : {}),
...(typeof filters.scrollTop === 'number' ? { scrollTop: filters.scrollTop } : {}),
...(typeof filters.displayCount === 'number' ? { displayCount: filters.displayCount } : {}),
...(filters.albums ? { albums: [...filters.albums] } : {}),
...(typeof filters.hasMore === 'boolean' ? { hasMore: filters.hasMore } : {}),
};
}
export const useAlbumBrowseSessionStore = create<AlbumBrowseSessionStore>((set, get) => ({
sortByServer: {},
returnStashByKey: {},
setSort: (serverId, sort) => {
if (!serverId) return;
set((s) => ({
sortByServer: { ...s.sortByServer, [serverId]: sort },
}));
},
stashReturnFilters: (serverId, surface, filters) => {
if (!serverId) return;
const key = returnStashKey(serverId, surface);
set((s) => ({
returnStashByKey: {
...s.returnStashByKey,
[key]: cloneReturnFilters(filters),
},
}));
},
clearReturnStash: (serverId, surface) => {
if (!serverId) return;
const key = returnStashKey(serverId, surface);
const next = { ...get().returnStashByKey };
delete next[key];
set({ returnStashByKey: next });
},
peekReturnStash: (serverId, surface) => {
if (!serverId) return null;
const stash = get().returnStashByKey[returnStashKey(serverId, surface)];
if (!stash) return null;
return cloneReturnFilters(stash);
},
}));
/** Scroll-restore target saved when leaving a browse surface for album detail. */
export function peekAlbumBrowseScrollRestore(
serverId: string,
surface: AlbumBrowseSurface,
): { scrollTop: number; displayCount: number } | null {
const stash = useAlbumBrowseSessionStore.getState().peekReturnStash(serverId, surface);
if (!stash) return null;
if (typeof stash.scrollTop !== 'number' || typeof stash.displayCount !== 'number') return null;
return {
scrollTop: Math.max(0, stash.scrollTop),
displayCount: Math.max(0, stash.displayCount),
};
}
/** Genre detail leave-restore (scoped per genre name). */
export function stashGenreDetailReturnFilters(
serverId: string,
genreName: string,
filters: AlbumBrowseReturnFilters,
): void {
if (!serverId || !genreName) return;
const key = genreDetailStashKey(serverId, genreName);
useAlbumBrowseSessionStore.setState((s) => ({
returnStashByKey: {
...s.returnStashByKey,
[key]: cloneReturnFilters(filters),
},
}));
}
export function clearGenreDetailReturnStash(serverId: string, genreName: string): void {
if (!serverId || !genreName) return;
const key = genreDetailStashKey(serverId, genreName);
useAlbumBrowseSessionStore.setState((s) => {
const next = { ...s.returnStashByKey };
delete next[key];
return { returnStashByKey: next };
});
}
export function peekGenreDetailReturnStash(
serverId: string,
genreName: string,
): AlbumBrowseReturnFilters | null {
if (!serverId || !genreName) return null;
const stash = useAlbumBrowseSessionStore.getState().returnStashByKey[genreDetailStashKey(serverId, genreName)];
if (!stash) return null;
return cloneReturnFilters(stash);
}
export function peekGenreDetailScrollRestore(
serverId: string,
genreName: string,
): { scrollTop: number; displayCount: number } | null {
const stash = peekGenreDetailReturnStash(serverId, genreName);
if (!stash) return null;
if (typeof stash.scrollTop !== 'number' || typeof stash.displayCount !== 'number') return null;
return {
scrollTop: Math.max(0, stash.scrollTop),
displayCount: Math.max(0, stash.displayCount),
};
}
export function albumBrowseSortForServer(
sortByServer: Record<string, AlbumBrowseSort>,
serverId: string,
): AlbumBrowseSort {
if (!serverId) return DEFAULT_ALBUM_BROWSE_SORT;
return sortEntryFor(sortByServer, serverId);
}
/** Map pathname to album grid browse surface, if any. */
/** All Albums browse route (`/albums`) — scoped live search target. */
export function isAlbumsBrowsePath(pathname: string): boolean {
return albumBrowseSurfaceForPath(pathname) === 'albums';
}
/** New Releases browse route (`/new-releases`) — scoped live search target. */
export function isNewReleasesBrowsePath(pathname: string): boolean {
return albumBrowseSurfaceForPath(pathname) === 'new-releases';
}
export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface | null {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
if (path === '/albums') return 'albums';
if (path === '/new-releases') return 'new-releases';
if (path === '/random/albums') return 'random-albums';
return null;
}
/** True when pathname is a single album detail route (`/album/:id`). */
export function isAlbumDetailPath(pathname: string): boolean {
return /^\/album\/[^/]+\/?$/.test(pathname.split('?')[0]?.replace(/\/$/, '') || pathname);
}
/** Single genre detail route (`/genres/:name`), not the genre cloud (`/genres`). */
export function isGenreDetailPath(pathname: string): boolean {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
return /^\/genres\/[^/]+$/.test(path);
}
export function genreDetailGenreFromPath(pathname: string): string | null {
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
const match = path.match(/^\/genres\/([^/]+)$/);
return match ? decodeURIComponent(match[1]) : null;
}
/** True when pathname is a single artist detail route (`/artist/:id`). */
export function isArtistDetailPath(pathname: string): boolean {
return /^\/artist\/[^/]+\/?$/.test(pathname);
}
/** True when pathname is a single composer detail route (`/composer/:id`). */
export function isComposerDetailPath(pathname: string): boolean {
return /^\/composer\/[^/]+\/?$/.test(pathname);
}
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean {
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname) || isComposerDetailPath(pathname);
}
@@ -0,0 +1,15 @@
/**
* Make an arbitrary album / playlist name safe to use as a file name on
* Windows, macOS, and Linux. Replaces every reserved character class with
* a dash, collapses runs of dots (which Windows treats specially) into one,
* trims leading/trailing whitespace and dots, and caps the length at 200
* characters so we don't hit MAX_PATH edges on Windows. Falls back to
* `download` when the sanitisation strips the name down to nothing.
*/
export function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
+8
View File
@@ -0,0 +1,8 @@
const NEW_ALBUM_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
export function isAlbumRecentlyAdded(created?: string): boolean {
if (!created) return false;
const createdMs = Date.parse(created);
if (!Number.isFinite(createdMs)) return false;
return Date.now() - createdMs <= NEW_ALBUM_WINDOW_MS;
}
@@ -0,0 +1,34 @@
import type { ColDef } from '@/utils/useTracklistColumns';
export function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string {
const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase());
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' · ');
}
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: '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 },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'playCount', i18nKey: 'trackPlayCount', minWidth: 60, defaultWidth: 80, required: false },
{ key: 'lastPlayed', i18nKey: 'trackLastPlayed', minWidth: 90, defaultWidth: 130, required: false },
{ key: 'bpm', i18nKey: 'trackBpm', minWidth: 50, defaultWidth: 70, required: false },
];
export type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre' | 'playCount' | 'lastPlayed' | 'bpm';
export const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration', 'playCount', 'bpm']);
export type SortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
export const SORTABLE_COLS = new Set<ColKey | 'album'>(['title', 'artist', 'album', 'favorite', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
export function isSortable(key: ColKey | string): key is SortKey {
return SORTABLE_COLS.has(key as ColKey);
}
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import {
albumArtistDisplayName,
deriveAlbumArtistRefs,
deriveAlbumHeaderArtistRefs,
} from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { makeSubsonicSong } from '@/test/helpers/factories';
const baseAlbum = (): SubsonicAlbum => ({
id: 'al-1',
name: 'Test Album',
artist: 'Joined A / B',
artistId: 'ar-first',
songCount: 2,
duration: 100,
});
describe('deriveAlbumArtistRefs', () => {
it('prefers the OpenSubsonic `artists` array when present', () => {
const album: SubsonicAlbum = {
...baseAlbum(),
artists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }],
};
expect(deriveAlbumArtistRefs(album)).toEqual(album.artists);
});
it('uses legacy artist + artistId when no structured refs', () => {
expect(deriveAlbumArtistRefs(baseAlbum())).toEqual([{ id: 'ar-first', name: 'Joined A / B' }]);
});
it('omits id when artistId is blank', () => {
expect(deriveAlbumArtistRefs({ ...baseAlbum(), artistId: ' ', artist: 'Solo' }))
.toEqual([{ name: 'Solo' }]);
});
it('coerces a single-object OpenSubsonic artists payload', () => {
const album: SubsonicAlbum = {
...baseAlbum(),
artists: { id: 'a1', name: 'Solo' } as unknown as SubsonicAlbum['artists'],
};
expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'a1', name: 'Solo' }]);
});
it('prefers OpenSubsonic displayArtist over legacy artist', () => {
const album: SubsonicAlbum = {
...baseAlbum(),
artist: 'Groove Armada',
displayArtist: 'Underworld',
};
expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'ar-first', name: 'Underworld' }]);
expect(albumArtistDisplayName(album)).toBe('Underworld');
});
});
describe('deriveAlbumHeaderArtistRefs', () => {
it('prefers the album-level `artists` array when present', () => {
const album: SubsonicAlbum = {
...baseAlbum(),
artists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }],
};
expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual(album.artists);
});
it('falls back to the first song with `albumArtists`', () => {
const album = baseAlbum();
const songs = [
makeSubsonicSong({
albumId: album.id,
album: album.name,
albumArtists: [{ id: 'b1', name: 'Beta' }, { name: 'Gamma' }],
}),
];
expect(deriveAlbumHeaderArtistRefs(album, songs)).toEqual(songs[0].albumArtists);
});
it('uses legacy artist + artistId when no structured refs', () => {
const album = baseAlbum();
const songs = [makeSubsonicSong({ albumId: album.id, album: album.name })];
expect(deriveAlbumHeaderArtistRefs(album, songs)).toEqual([{ id: 'ar-first', name: 'Joined A / B' }]);
});
it('omits id when artistId is blank', () => {
const album: SubsonicAlbum = { ...baseAlbum(), artistId: ' ', artist: 'Solo' };
expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual([{ name: 'Solo' }]);
});
});
@@ -0,0 +1,48 @@
import type { SubsonicAlbum, SubsonicOpenArtistRef, SubsonicSong } from '@/api/subsonicTypes';
import { coerceOpenArtistRefs } from '@/features/artist';
function nonEmpty(refs: SubsonicOpenArtistRef[]): refs is SubsonicOpenArtistRef[] {
return refs.length > 0;
}
/**
* Structured album-artist credits without the album-detail Song fallback.
* Used wherever only the album object is available (cards, rails). Prefers the
* OpenSubsonic `artists` array; falls back to legacy `artist` + `artistId`.
*/
export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] {
const albumArtists = coerceOpenArtistRefs(album.artists);
if (nonEmpty(albumArtists)) return albumArtists;
const display = album.displayArtist?.trim();
const legacy = album.artist?.trim();
const name = display || legacy || '—';
const id = album.artistId?.trim();
return id ? [{ id, name }] : [{ name }];
}
/** Single-line album-artist label for cards and rails (matches `deriveAlbumArtistRefs`). */
export function albumArtistDisplayName(album: SubsonicAlbum): string {
const parts = deriveAlbumArtistRefs(album)
.map(r => r.name?.trim() ?? '')
.filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : '—';
}
/**
* OpenSubsonic album credits for the album-detail header.
* Prefer the album's `artists` array, then any child song's `albumArtists`
* (some servers only attach the structured list at song level); fall back to
* the legacy `artist` + `artistId` strings.
*/
export function deriveAlbumHeaderArtistRefs(
album: SubsonicAlbum,
songs: SubsonicSong[],
): SubsonicOpenArtistRef[] {
const albumArtists = coerceOpenArtistRefs(album.artists);
if (nonEmpty(albumArtists)) return albumArtists;
for (const s of songs) {
const songAlbumArtists = coerceOpenArtistRefs(s.albumArtists);
if (nonEmpty(songAlbumArtists)) return songAlbumArtists;
}
return deriveAlbumArtistRefs(album);
}
+365
View File
@@ -0,0 +1,365 @@
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { coverArtRef } from '@/cover/ref';
import { loadCoverBlobForExport } from '@/cover/integrations/export';
import PsysonicLogo from '@/components/PsysonicLogo';
export type ExportFormat = 'story' | 'square' | 'twitter';
export type ExportGridSize = 3 | 4 | 5;
export interface ExportAlbumCardOptions {
albums: SubsonicAlbum[];
format: ExportFormat;
gridSize: ExportGridSize;
/** Footer label like "Top Albums". */
title: string;
/** Footer secondary label like the period or "Most Played". */
periodLabel?: string;
/** Footer-right text shown next to the wordmark, usually a period or count. */
meta?: string;
/** Optional explicit accent override; defaults to the document's `--accent`. */
accent?: string;
/** Optional explicit background override; defaults to the document's `--bg-primary`. */
background?: string;
/** Set to true while rendering a low-res preview. Skips slow font/quality settings. */
preview?: boolean;
}
const DIMENSIONS: Record<ExportFormat, { w: number; h: number }> = {
story: { w: 1080, h: 1920 },
square: { w: 1080, h: 1080 },
twitter: { w: 1200, h: 675 },
};
// Preview canvas resolution. 540 left text and album covers visibly upscaled
// (and so blurry) when CSS stretched the canvas back up to the modal width
// — match the full export width for Square/Story (1080) so the preview is
// pixel-crisp at any modal size, and only Twitter scales down (1200 → 1080).
const PREVIEW_MAX_WIDTH = 1080;
/** Reads a `--var` from `document.documentElement`, with optional fallback. */
function readThemeVar(name: string, fallback: string): string {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
/** Quick contrast check — returns true if the background is light. */
function isLight(hex: string): boolean {
// Accepts `#RRGGBB`, `#RGB`, or `rgb(r, g, b)` — fall back to dark.
const m = hex.match(/^#([0-9a-f]{6})$/i) || hex.match(/^#([0-9a-f]{3})$/i);
if (!m) {
const rgb = hex.match(/rgb\(\s*(\d+)\D+(\d+)\D+(\d+)/i);
if (rgb) return (Number(rgb[1]) + Number(rgb[2]) + Number(rgb[3])) / 3 > 160;
return false;
}
const v = m[1];
const expand = v.length === 3 ? v.split('').map(c => c + c).join('') : v;
const r = parseInt(expand.slice(0, 2), 16);
const g = parseInt(expand.slice(2, 4), 16);
const b = parseInt(expand.slice(4, 6), 16);
return (r + g + b) / 3 > 160;
}
async function loadAlbumCover(album: SubsonicAlbum, displayCssPx: number, signal?: AbortSignal): Promise<ImageBitmap | null> {
if (!album.coverArt) return null;
try {
const blob = await loadCoverBlobForExport(coverArtRef(album.coverArt), displayCssPx, signal);
if (!blob) return null;
return await createImageBitmap(blob);
} catch {
return null;
}
}
/** Decodes the Psysonic wordmark SVG into an Image, ready for drawImage.
* `targetHeight` is informational actual scaling happens at drawImage time. */
async function loadWordmark(color: string): Promise<HTMLImageElement> {
const svgMarkup = getCachedLogoSvg(color);
const blob = new Blob([svgMarkup], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
try {
const img = new Image();
img.decoding = 'async';
img.src = url;
await img.decode();
return img;
} finally {
setTimeout(() => URL.revokeObjectURL(url), 0);
}
}
/** Renders the PsysonicLogo component to an SVG string with a single solid
* color baked in (both gradient stops set to the same value), so the wordmark
* stays uniformly readable on the export background regardless of theme.
* The naive `var(--logo-color-start)` regex misses the nested fallback
* `var(--logo-color-start, var(--accent))` we use exact-string replace. */
let cachedLogoSvgKey = '';
let cachedLogoSvg = '';
function getCachedLogoSvg(color: string): string {
if (cachedLogoSvgKey === color && cachedLogoSvg) return cachedLogoSvg;
const raw = renderToStaticMarkup(React.createElement(PsysonicLogo, { gradientIdSuffix: 'export' }));
const swapped = raw
.replace('var(--logo-color-start, var(--accent))', color)
.replace('var(--logo-color-end, var(--accent-2))', color);
cachedLogoSvg = swapped;
cachedLogoSvgKey = color;
return swapped;
}
/**
* Renders a Pano-Scrobbler-style "Top Albums" image to a canvas and returns it
* as a Blob (PNG). Caller is responsible for saving the blob to disk.
*
* The function reads accent + background colors from the active theme so the
* exported image matches the in-app look. Cover art is loaded through the
* existing IndexedDB cache (`getCachedBlob`), so repeated exports are cheap.
*/
export async function renderAlbumCardCanvas(opts: ExportAlbumCardOptions): Promise<HTMLCanvasElement> {
const { albums, format, gridSize, preview } = opts;
const dims = DIMENSIONS[format];
const scale = preview ? Math.min(1, PREVIEW_MAX_WIDTH / dims.w) : 1;
const w = Math.round(dims.w * scale);
const h = Math.round(dims.h * scale);
const accent = opts.accent ?? readThemeVar('--accent', '#CBA6F7');
const bg = opts.background ?? readThemeVar('--bg-primary', '#1E1E2E');
const fgPrimary = readThemeVar('--text-primary', isLight(bg) ? '#11111B' : '#CDD6F4');
const fgMuted = readThemeVar('--text-muted', isLight(bg) ? '#444' : '#9399B2');
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('canvas 2d unavailable');
// ── Background ─────────────────────────────────────────────────────────
// Subtle vertical gradient: bg → bg with a slight accent tint at the bottom.
const bgGrad = ctx.createLinearGradient(0, 0, 0, h);
bgGrad.addColorStop(0, bg);
bgGrad.addColorStop(1, mixHex(bg, accent, 0.12));
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, w, h);
// ── Layout ───────────────────────────────────────────────────────────────
// Header / footer have format-specific minimum heights so the title and
// logo never collide visually with the grid even when the grid is
// height-bounded (Square, Twitter). Landscape (Twitter) needs the largest
// proportional band because the card is short. Portrait (Story) the
// smallest because there's already plenty of vertical room.
const pad = Math.round(w * 0.045);
const gap = Math.round(w * 0.012);
const isLandscape = format === 'twitter';
const isPortrait = format === 'story';
// Story stacks logo above the meta-label, so it needs the most header room.
const headerMinRatio = isPortrait ? 0.16 : isLandscape ? 0.18 : 0.13;
// Square + Story have a URL footer; Twitter doesn't (URL lives in header).
const footerMinRatio = isLandscape ? 0.05 : isPortrait ? 0.07 : 0.08;
const headerMin = Math.round(h * headerMinRatio);
const footerMin = Math.round(h * footerMinRatio);
let headerH = headerMin;
let footerH = footerMin;
const horizontalTile = Math.floor((w - pad * 2 - gap * (gridSize - 1)) / gridSize);
let availableH = h - headerH - footerH;
let verticalTile = Math.floor((availableH - gap * (gridSize - 1)) / gridSize);
let tileSize = Math.min(horizontalTile, verticalTile);
// If we're width-bounded the grid leaves vertical slack — push header
// (60%) and footer (40%) outward to absorb it. Header/footer content
// stays anchored away from the grid edge so it can't drift in.
const gridPxH0 = tileSize * gridSize + gap * (gridSize - 1);
const verticalSlack = availableH - gridPxH0;
if (verticalSlack > 0) {
headerH += Math.round(verticalSlack * 0.6);
footerH += verticalSlack - Math.round(verticalSlack * 0.6);
availableH = h - headerH - footerH;
verticalTile = Math.floor((availableH - gap * (gridSize - 1)) / gridSize);
tileSize = Math.min(horizontalTile, verticalTile);
}
const gridPxW = tileSize * gridSize + gap * (gridSize - 1);
const gridFinalH = tileSize * gridSize + gap * (gridSize - 1);
const gridX = pad + Math.round((w - pad * 2 - gridPxW) / 2);
const gridY = headerH + Math.round((h - headerH - footerH - gridFinalH) / 2);
const headerHasSlack = verticalSlack > 0;
// ── Header: format-specific ──────────────────────────────────────────────
// Story (portrait): logo centered, meta-label centered below it.
// Twitter (landscape): logo left, meta center, url right (small).
// Square: logo left, meta right.
const logo = await loadWordmark(accent).catch(() => null);
const logoRatio = logo ? (logo.naturalWidth / logo.naturalHeight || 4.4) : 4.4;
const drawLogoAt = (lx: number, ly: number, lh: number) => {
if (logo) {
ctx.drawImage(logo, lx, ly, Math.round(lh * logoRatio), lh);
} else {
ctx.fillStyle = accent;
ctx.font = `700 ${Math.round(lh * 0.78)}px "Space Grotesk", "Inter", system-ui, sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('psysonic', lx, ly);
}
};
// Header label is hardcoded English ("Top Albums") so a shared image is
// legible to anyone, regardless of the exporter's UI language. Caller can
// still override via `opts.meta` for special editions ("Top Albums 2026").
const headerLabel = opts.meta ?? 'Top Albums';
if (isPortrait) {
// Story: stacked logo + label, both centered.
const logoH = Math.max(36, Math.round(headerMin * 0.36));
const logoW = Math.round(logoH * logoRatio);
const metaSize = Math.max(18, Math.round(headerMin * 0.18));
const stackGap = Math.round(headerMin * 0.08);
const totalH = logoH + stackGap + metaSize;
const stackTop = headerHasSlack
? Math.round(pad * 0.85)
: Math.round((headerMin - totalH) / 2);
const logoX = Math.round((w - logoW) / 2);
drawLogoAt(logoX, stackTop, logoH);
ctx.font = `500 ${metaSize}px "Inter", system-ui, sans-serif`;
ctx.fillStyle = fgMuted;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(headerLabel, Math.round(w / 2), stackTop + logoH + stackGap);
} else if (isLandscape) {
// Twitter: logo left, label centered, url right (smaller).
const logoH = Math.max(30, Math.round(headerMin * 0.42));
const headerCenterY = headerHasSlack
? Math.round(pad * 0.85) + Math.round(logoH / 2)
: Math.round(headerMin / 2);
drawLogoAt(pad, headerCenterY - Math.round(logoH / 2), logoH);
const metaSize = Math.max(16, Math.round(headerMin * 0.22));
ctx.font = `600 ${metaSize}px "Inter", system-ui, sans-serif`;
ctx.fillStyle = fgPrimary;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(headerLabel, Math.round(w / 2), headerCenterY);
const urlSize = Math.max(13, Math.round(headerMin * 0.16));
ctx.font = `500 ${urlSize}px "Inter", system-ui, sans-serif`;
ctx.fillStyle = fgMuted;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText('www.psysonic.de', w - pad, headerCenterY);
} else {
// Square: logo left, label right.
const logoH = Math.max(28, Math.round(headerMin * 0.40));
const headerCenterY = headerHasSlack
? Math.round(pad * 0.85) + Math.round(logoH / 2)
: Math.round(headerMin / 2);
drawLogoAt(pad, headerCenterY - Math.round(logoH / 2), logoH);
const metaSize = Math.max(14, Math.round(headerMin * 0.22));
ctx.font = `500 ${metaSize}px "Inter", system-ui, sans-serif`;
ctx.fillStyle = fgMuted;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(headerLabel, w - pad, headerCenterY);
}
// ── Tiles ────────────────────────────────────────────────────────────────
// Match the export tile resolution so preview covers downsample crisply
// into the (now full-width) canvas instead of upscaling 256 → ~300 and
// blurring every album thumbnail.
const desiredTilePx = 600;
const needed = gridSize * gridSize;
const tilesAlbums = albums.slice(0, needed);
const covers = await Promise.all(tilesAlbums.map(a => loadAlbumCover(a, desiredTilePx)));
for (let i = 0; i < tilesAlbums.length; i++) {
const album = tilesAlbums[i];
const col = i % gridSize;
const row = Math.floor(i / gridSize);
const x = gridX + col * (tileSize + gap);
const y = gridY + row * (tileSize + gap);
// Cover or fallback panel.
const cover = covers[i];
if (cover) {
ctx.drawImage(cover, x, y, tileSize, tileSize);
} else {
ctx.fillStyle = mixHex(bg, accent, 0.15);
ctx.fillRect(x, y, tileSize, tileSize);
}
// Info strip — narrow bar at the bottom of the cover with the rank on
// the left and the play-count on the right. Full-width strip integrates
// visually into the cover (as opposed to a floating pill).
const stripH = Math.max(20, Math.round(tileSize * 0.13));
const stripY = y + tileSize - stripH;
ctx.fillStyle = 'rgba(0, 0, 0, 0.68)';
ctx.fillRect(x, stripY, tileSize, stripH);
const stripFontSize = Math.round(stripH * 0.52);
const stripPadX = Math.round(stripH * 0.45);
const stripCenterY = stripY + stripH / 2 + 1;
// Rank on the left.
ctx.font = `800 ${stripFontSize}px "Space Grotesk", "Inter", system-ui, sans-serif`;
ctx.fillStyle = '#fff';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), x + stripPadX, stripCenterY);
// Plays on the right (only when available).
const plays = album.playCount;
if (plays && plays > 0) {
ctx.font = `600 ${stripFontSize}px "Inter", system-ui, sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.88)';
ctx.textAlign = 'right';
ctx.fillText(`${plays} Plays`, x + tileSize - stripPadX, stripCenterY);
}
}
// ── Footer: URL centered (Story + Square only; Twitter has it in header) ─
if (!isLandscape) {
const urlSize = Math.max(13, Math.round(footerMin * 0.36));
ctx.font = `500 ${urlSize}px "Inter", system-ui, sans-serif`;
ctx.fillStyle = fgMuted;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const footerCenterY = h - Math.round(footerMin / 2);
ctx.fillText('www.psysonic.de', Math.round(w / 2), footerCenterY);
}
return canvas;
}
export async function exportAlbumCardBlob(opts: ExportAlbumCardOptions): Promise<Blob> {
const canvas = await renderAlbumCardCanvas(opts);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(b => (b ? resolve(b) : reject(new Error('toBlob returned null'))), 'image/png');
});
}
// ──────────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────────
/** Mixes two hex colors by `t` ∈ [0..1]. Falls back to `a` when parsing fails. */
function mixHex(a: string, b: string, t: number): string {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
if (!ca || !cb) return a;
const r = Math.round(ca.r + (cb.r - ca.r) * t);
const g = Math.round(ca.g + (cb.g - ca.g) * t);
const bl = Math.round(ca.b + (cb.b - ca.b) * t);
return `rgb(${r}, ${g}, ${bl})`;
}
function hexToRgb(input: string): { r: number; g: number; b: number } | null {
const trimmed = input.trim();
const hex = trimmed.match(/^#([0-9a-f]{3,8})$/i);
if (hex) {
let v = hex[1];
if (v.length === 3) v = v.split('').map(c => c + c).join('');
if (v.length === 6 || v.length === 8) {
return {
r: parseInt(v.slice(0, 2), 16),
g: parseInt(v.slice(2, 4), 16),
b: parseInt(v.slice(4, 6), 16),
};
}
}
const rgb = trimmed.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i);
if (rgb) return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) };
return null;
}
+286
View File
@@ -0,0 +1,286 @@
import { getAlbumList } from '@/api/subsonicLibrary';
import { coverArtRef } from '@/cover/ref';
import { loadCoverBlobForExport } from '@/cover/integrations/export';
import type { SubsonicAlbum } from '@/api/subsonicTypes';
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
import { writeFile } from '@tauri-apps/plugin-fs';
import { downloadDir, join } from '@tauri-apps/api/path';
import { useAuthStore } from '@/store/authStore';
// Catppuccin Macchiato palette
const M = {
crust: '#181926',
mantle: '#1e2030',
base: '#24273a',
surface0: '#363a4f',
surface1: '#494d64',
surface2: '#5b6078',
text: '#cad3f5',
subtext1: '#b8c0e0',
subtext0: '#a5adcb',
mauve: '#c6a0f6',
lavender: '#b7bdf8',
overlay2: '#939ab7',
};
const W = 1080;
const PAD = 56;
const COVER_SIZE = 52;
const ROW_H = 72;
const COVER_PAD = (ROW_H - COVER_SIZE) / 2;
const TEXT_X = PAD + COVER_SIZE + 18;
const TEXT_W = W - TEXT_X - PAD;
const HEADER_H = 260;
const FOOTER_H = 72;
const MAX_PER_PAGE = 20;
function clampText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) return text;
let t = text;
while (ctx.measureText(t + '…').width > maxW && t.length > 0) t = t.slice(0, -1);
return t + '…';
}
async function loadAlbumCoverBitmap(album: SubsonicAlbum): Promise<ImageBitmap | null> {
if (!album.coverArt) return null;
try {
const blob = await loadCoverBlobForExport(coverArtRef(album.coverArt), COVER_SIZE);
if (!blob) return null;
return await createImageBitmap(blob);
} catch { return null; }
}
async function loadLogo(): Promise<HTMLImageElement | null> {
try {
const res = await fetch('/psysonic-inapp-logo.svg');
if (!res.ok) return null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
return new Promise(resolve => {
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
} catch { return null; }
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
async function renderPage(
albums: SubsonicAlbum[],
covers: (ImageBitmap | null)[],
logo: HTMLImageElement | null,
now: Date,
totalCount: number,
pageNum: number,
totalPages: number,
globalOffset: number,
): Promise<Blob> {
const H = HEADER_H + albums.length * ROW_H + FOOTER_H;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
// Background
ctx.fillStyle = M.base;
ctx.fillRect(0, 0, W, H);
const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_H);
headerGrad.addColorStop(0, M.mantle);
headerGrad.addColorStop(1, M.base);
ctx.fillStyle = headerGrad;
ctx.fillRect(0, 0, W, HEADER_H);
// Logo
const LOGO_H = 52;
const logoY = 44;
if (logo) {
const logoW = logo.naturalWidth && logo.naturalHeight
? Math.round(LOGO_H * (logo.naturalWidth / logo.naturalHeight))
: LOGO_H * 4;
ctx.drawImage(logo, W / 2 - logoW / 2, logoY, logoW, LOGO_H);
}
// Title
ctx.textAlign = 'center';
ctx.font = '700 42px system-ui, sans-serif';
ctx.fillStyle = M.text;
ctx.fillText('Die neuesten Alben', W / 2, logoY + LOGO_H + 52);
// Date + page indicator
const dateStr = now.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
const pageStr = totalPages > 1 ? ` · Teil ${pageNum} / ${totalPages}` : '';
ctx.font = '400 18px system-ui, sans-serif';
ctx.fillStyle = M.subtext0;
ctx.fillText(dateStr + pageStr, W / 2, logoY + LOGO_H + 82);
// Count badge (total, only on first page)
if (pageNum === 1) {
const badgeText = `${totalCount} ${totalCount !== 1 ? 'Alben' : 'Album'}`;
ctx.font = '600 13px system-ui, sans-serif';
const badgeW = ctx.measureText(badgeText).width + 24;
const badgeX = W / 2 - badgeW / 2;
const badgeY = logoY + LOGO_H + 102;
roundRect(ctx, badgeX, badgeY, badgeW, 24, 12);
ctx.fillStyle = M.surface0;
ctx.fill();
ctx.fillStyle = M.mauve;
ctx.fillText(badgeText, W / 2, badgeY + 16);
}
// Divider
ctx.strokeStyle = M.surface1;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, HEADER_H - 16);
ctx.lineTo(W - PAD, HEADER_H - 16);
ctx.stroke();
// Album rows
for (let i = 0; i < albums.length; i++) {
const album = albums[i];
const cover = covers[i];
const rowY = HEADER_H + i * ROW_H;
const coverY = rowY + COVER_PAD;
const globalIdx = globalOffset + i;
if (i % 2 === 0) {
ctx.fillStyle = 'rgba(54,58,79,0.35)';
ctx.fillRect(0, rowY, W, ROW_H);
}
// Index
ctx.textAlign = 'right';
ctx.font = '500 13px system-ui, sans-serif';
ctx.fillStyle = M.surface2;
ctx.fillText(String(globalIdx + 1), PAD - 12, coverY + COVER_SIZE / 2 + 5);
// Cover
roundRect(ctx, PAD, coverY, COVER_SIZE, COVER_SIZE, 8);
ctx.fillStyle = M.surface0;
ctx.fill();
if (cover) {
ctx.save();
roundRect(ctx, PAD, coverY, COVER_SIZE, COVER_SIZE, 8);
ctx.clip();
ctx.drawImage(cover, PAD, coverY, COVER_SIZE, COVER_SIZE);
ctx.restore();
} else {
ctx.font = '28px system-ui';
ctx.fillStyle = M.surface2;
ctx.textAlign = 'center';
ctx.fillText('♪', PAD + COVER_SIZE / 2, coverY + COVER_SIZE / 2 + 10);
}
// Text row
ctx.textAlign = 'left';
ctx.font = '600 17px system-ui, sans-serif';
const lineY = coverY + COVER_SIZE / 2 + 6;
const sep = ' — ';
const artistClamp = clampText(ctx, albumArtistDisplayName(album), TEXT_W * 0.42);
const artistW = ctx.measureText(artistClamp).width;
const sepW = ctx.measureText(sep).width;
const remaining = TEXT_W - artistW - sepW;
const albumClamp = clampText(ctx, album.name, remaining * 0.65);
const albumW = ctx.measureText(albumClamp).width;
ctx.fillStyle = M.mauve;
ctx.fillText(artistClamp, TEXT_X, lineY);
ctx.fillStyle = M.overlay2;
ctx.fillText(sep, TEXT_X + artistW, lineY);
ctx.fillStyle = M.text;
ctx.fillText(albumClamp, TEXT_X + artistW + sepW, lineY);
if (album.year || album.genre) {
ctx.font = '400 15px system-ui, sans-serif';
let cx = TEXT_X + artistW + sepW + albumW;
if (album.year) {
ctx.fillStyle = M.subtext0;
const yearPart = ` (${album.year})`;
ctx.fillText(yearPart, cx, lineY);
cx += ctx.measureText(yearPart).width;
}
if (album.genre) {
ctx.fillStyle = M.subtext0;
const dashPart = ' — ';
ctx.fillText(dashPart, cx, lineY);
cx += ctx.measureText(dashPart).width;
ctx.fillStyle = M.lavender;
ctx.fillText(clampText(ctx, album.genre, TEXT_X + TEXT_W - cx), cx, lineY);
}
}
if (i < albums.length - 1) {
ctx.strokeStyle = M.surface0;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, rowY + ROW_H);
ctx.lineTo(W - PAD, rowY + ROW_H);
ctx.stroke();
}
}
// Footer
ctx.textAlign = 'center';
ctx.font = '400 13px system-ui, sans-serif';
ctx.fillStyle = M.overlay2;
ctx.fillText('www.psysonic.de', W / 2, H - FOOTER_H / 2 + 6);
return new Promise(resolve => canvas.toBlob(b => resolve(b!), 'image/png'));
}
export async function exportNewAlbumsImage(since: number): Promise<{ count: number; paths: string[] } | null> {
const albums = await getAlbumList('newest', 500);
if (albums.length === 0) return null;
const newAlbums = since > 0
? albums.filter(a => a.created && new Date(a.created).getTime() >= since)
: albums;
if (newAlbums.length === 0) return null;
newAlbums.sort((a, b) => a.artist.localeCompare(b.artist, 'de') || a.name.localeCompare(b.name, 'de'));
// Chunk into pages
const pages: SubsonicAlbum[][] = [];
for (let i = 0; i < newAlbums.length; i += MAX_PER_PAGE) {
pages.push(newAlbums.slice(i, i + MAX_PER_PAGE));
}
const now = new Date();
const logo = await loadLogo();
const { downloadFolder } = useAuthStore.getState();
const folder = downloadFolder || await downloadDir();
const timestamp = now.toISOString().slice(0, 10);
const paths: string[] = [];
for (let p = 0; p < pages.length; p++) {
const page = pages[p];
const covers = await Promise.all(
page.map(a => loadAlbumCoverBitmap(a)),
);
const blob = await renderPage(page, covers, logo, now, newAlbums.length, p + 1, pages.length, p * MAX_PER_PAGE);
const suffix = pages.length > 1 ? `-${p + 1}` : '';
const filename = `psysonic-new-albums-${timestamp}${suffix}.png`;
const filePath = await join(folder, filename);
await writeFile(filePath, new Uint8Array(await blob.arrayBuffer()));
paths.push(filePath);
}
return { count: newAlbums.length, paths };
}
@@ -1,6 +1,6 @@
import React, { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAlbumDetailBack } from '@/hooks/useAlbumDetailBack';
import { useAlbumDetailBack } from '@/features/album';
import {
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
@@ -8,7 +8,7 @@ import {
isArtistsBrowsePath,
useArtistBrowseSessionStore,
} from '@/features/artist/store/artistBrowseSessionStore';
import { isArtistDetailPath } from '@/store/albumBrowseSessionStore';
import { isArtistDetailPath } from '@/features/album';
import { shouldRestoreArtistBrowseSession } from '@/utils/navigation/albumDetailNavigation';
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
+1 -1
View File
@@ -3,7 +3,7 @@ import { useArtistCoverRef } from '@/cover/useLibraryCoverRef';
import type { SubsonicArtist, SubsonicAlbum } from '@/api/subsonicTypes';
import { useEffect, useState, Fragment, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import AlbumCard from '@/components/AlbumCard';
import { AlbumCard } from '@/features/album';
import { ArrowDownUp } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '@/store/playerStore';
+1 -1
View File
@@ -9,7 +9,7 @@ import { useFavoritesData } from '@/features/favorites/hooks/useFavoritesData';
import { useFavoritesSongFiltering } from '@/features/favorites/hooks/useFavoritesSongFiltering';
import { useFavoritesSelection } from '@/features/favorites/hooks/useFavoritesSelection';
import { useBulkPlPickerOutsideClick } from '@/hooks/useBulkPlPickerOutsideClick';
import AlbumRow from '@/components/AlbumRow';
import { AlbumRow } from '@/features/album';
import { ArtistRow } from '@/features/artist';
import { usePlayerStore } from '@/store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -22,13 +22,13 @@ import {
} from '@/utils/library/libraryDevLog';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '@/hooks/useNavigateToAlbum';
import { useNavigateToAlbum } from '@/features/album';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { useTranslation } from 'react-i18next';
import { albumArtistDisplayName } from '@/utils/album/deriveAlbumHeaderArtistRefs';
import { albumArtistDisplayName } from '@/features/album';
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
import type { SubsonicSong } from '@/api/subsonicTypes';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
@@ -16,7 +16,7 @@ import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
import { CoverArtImage } from '@/cover/CoverArtImage';
import { albumCoverRefForSong } from '@/cover/ref';
import { showToast } from '@/utils/ui/toast';
import { albumArtistDisplayName } from '@/utils/album/deriveAlbumHeaderArtistRefs';
import { albumArtistDisplayName } from '@/features/album';
import { useShareSearch } from '@/features/search/hooks/useShareSearch';
import ShareSearchResults from '@/features/search/components/ShareSearchResults';
import {
@@ -1,7 +1,7 @@
import type { KeyboardEvent, MouseEvent } from 'react';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import type { LiveSearchScope } from '@/store/liveSearchScopeStore';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/store/albumBrowseSessionStore';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isArtistsBrowsePath } from '@/features/artist';
import { isComposersBrowsePath } from '@/store/composerBrowseSessionStore';
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/store/albumBrowseSessionStore';
import { isAlbumsBrowsePath, isNewReleasesBrowsePath } from '@/features/album';
import { isArtistsBrowsePath } from '@/features/artist';
import { isTracksBrowsePath } from '@/store/advancedSearchSessionStore';
import { isComposersBrowsePath } from '@/store/composerBrowseSessionStore';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '@/hooks/useNavigateToAlbum';
import { useNavigateToAlbum } from '@/features/album';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/authStore';
import {
@@ -6,7 +6,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
import { useLocation, useNavigate, useNavigationType, useSearchParams } from 'react-router-dom';
import { SlidersVertical, Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import AlbumRow from '@/components/AlbumRow';
import { AlbumRow } from '@/features/album';
import { ArtistRow } from '@/features/artist';
import PagedSongList from '@/components/PagedSongList';
import CustomSelect from '@/ui/CustomSelect';
@@ -15,7 +15,7 @@ import { tooltipAttrs } from '@/ui/tooltipAttrs';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { isAdvancedSearchLeaveTargetPath } from '@/store/albumBrowseSessionStore';
import { isAdvancedSearchLeaveTargetPath } from '@/features/album';
import {
isAdvancedSearchPath,
isAdvancedSearchPanelPath,
@@ -12,7 +12,7 @@ import {
renderAlbumCardCanvas,
ExportFormat,
ExportGridSize,
} from '@/utils/export/exportAlbumCard';
} from '@/features/album';
interface Props {
open: boolean;
+1 -1
View File
@@ -5,7 +5,7 @@ import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Share2 } from 'lucide-react';
import { formatHumanHoursMinutes } from '@/utils/format/formatHumanDuration';
import AlbumRow from '@/components/AlbumRow';
import { AlbumRow } from '@/features/album';
import StatsExportModal from '@/features/stats/components/StatsExportModal';
import PlayerStatisticsPanel from '@/features/stats/components/PlayerStatisticsPanel';
import StatisticsTabBar from '@/features/stats/components/StatisticsTabBar';