Files
Psychotoxical-psysonic/src/pages/LosslessAlbums.tsx
T
Psychotoxical d49424e95b feat(settings): add a Compact buttons appearance toggle (#1189)
* feat(ui): prototype compact hero and toolbar buttons (Large/Small appearance setting)

* feat(settings): rename action-button size toggle to "Compact buttons"

Promote the hero-button prototype to a real, app-wide setting.

- rename heroButtonSize → buttonSize, data-hero-buttons → data-button-size,
  hero-action-bar/hero-btn-label → compact-action-bar/compact-btn-label
- relabel "Hero buttons" → "Compact buttons" and broaden the description
  (action + toolbar buttons across detail pages and browse views) in all 11 locales
- move the feature CSS out of cover-lightbox.css into its own compact-buttons.css
- add tests: themeStore buttonSize toggle, SelectionToggleButton

* docs(changelog): compact buttons appearance toggle (#1189)

* docs(credits): compact buttons appearance toggle (#1189)
2026-06-25 14:49:09 +02:00

406 lines
15 KiB
TypeScript

import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AlbumCard from '../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 '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { useZipDownloadStore } from '../store/zipDownloadStore';
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 '../components/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 '../utils/album/deriveAlbumHeaderArtistRefs';
import {
albumBrowseSortForServer,
useAlbumBrowseSessionStore,
} from '../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>
);
}