mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(album): co-locate album feature into features/album
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 doesn’t 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 row’s 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 effect’s 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 effect’s 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"> · </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 effect’s 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],
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 effect’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user