mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
4ac373a65b
* feat(artists): scoped live search badge replaces page filter Move Artists browse text search into the header Live Search with a page scope badge (Users icon), field-local undo, and double-click/backspace to clear scope. Block the live-search dropdown while scoped so results only filter the Artists grid; mobile overlay follows the same rules. * fix(artists): plain grid for scoped search fixes broken card layout Route the browse grid through VirtualCardGrid, switch to non-virtual CSS grid when live search filters the catalog, reset scroll on filter changes, and skip content-visibility on plain tiles to avoid blank/black cards. * fix(search): scope badge double-Backspace and single clear control Require two Backspaces on an empty scoped field after prior text input; one Backspace still clears the badge when the field was never filled. Move live-search clear/advanced controls inside the field pill, drop the extra outer clear button, and use type=text to avoid native search clears. * fix(search): drop duplicate outer live-search clear button Keep the native in-field clear on type=search and the original pill layout; remove only the extra × control outside the search border. Reset dropdown state when the query is cleared via the native control. * refactor(search): generic scoped browse query helper, drop dead code Rename artistsBrowseSearchQuery to scopedBrowseSearchQuery with an expectedScope argument; wire Artists via useScopedBrowseSearchQuery. Remove unused liveSearchScoped dropdown helper (scoped mode blocks it). * feat(search): ghost scope badge and single-click badge remove After clearing the artists scope on /artists, show a faded ghost chip to restore page-only search while keeping the global search placeholder. Active badge removes on one click; tooltips and styles updated. * feat(search): scoped live search for All Albums and New Releases Wire albums and newReleases scope badges with debounced album title search (local index title-only FTS + filtered search3). Plain grid, scroll reset, and session query restore on album grid browse pages. * fix(browse): preserve scroll restore after album/artist detail back Only reset in-page scroll when filter resetKey changes, not when isScrollRestorePending clears after session restore. * feat(search): scoped live search for Tracks browse Wire /tracks to header live search with wide title/artist/album FTS, hide hero and discovery rails while search is active, and remove the inline search field from the browse list. * fix(search): clear header query when leaving scoped browse pages Prevent global live search from firing on album/detail routes after a scoped browse query; browse session stashes still restore on back. * fix(tracks): restore scroll after back from detail during scoped search Hold stashed song results across fetchSongPage churn, defer leave-stash teardown past AppShell scroll reset, restore tracks scroll after the list is ready, and save scroll snapshot when opening artist from song context menu. * fix(tracks): hide discovery headings during scoped search Hide the page subtitle and "Browse all tracks" section title when tracks search is active, matching hero/rails chrome behavior. * feat(search): scoped live search for Composers browse Wire /composers to header live search with composers scope badge, session stash, scroll restore, and plain grid/list during text filter. Remove the in-page filter input; add i18n and navigation helpers. * docs: CHANGELOG and credits for scoped browse live search (PR #938)
352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
|
|
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
|
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
|
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
|
import { dedupeById } from '../utils/dedupeById';
|
|
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
|
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
|
|
import AlbumCard from '../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 { useOfflineStore } from '../store/offlineStore';
|
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { join } from '@tauri-apps/api/path';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
|
import { useRangeSelection } from '../hooks/useRangeSelection';
|
|
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
|
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
|
|
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
|
import OverlayScrollArea from '../components/OverlayScrollArea';
|
|
import { NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
|
|
import { useAsyncInpagePagination } from '../hooks/useAsyncInpagePagination';
|
|
import { useInpageScrollSentinel } from '../hooks/useInpageScrollSentinel';
|
|
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
|
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
|
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hooks/useAlbumGridBrowseFilters';
|
|
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
|
import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
|
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
|
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
|
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
|
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
|
import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters';
|
|
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
|
|
|
const PAGE_SIZE = 30;
|
|
|
|
function sanitizeFilename(name: string): string {
|
|
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)));
|
|
return dedupeById(results.flat()).sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
|
}
|
|
|
|
export default function NewReleases() {
|
|
const { t } = useTranslation();
|
|
const perfFlags = usePerfProbeFlags();
|
|
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
|
const auth = useAuthStore();
|
|
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
|
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
|
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: true });
|
|
const {
|
|
selectedGenres,
|
|
setSelectedGenres,
|
|
initialAlbums,
|
|
initialHasMore,
|
|
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
|
|
const restoringSessionRef = useRef(initialAlbums != null);
|
|
|
|
const newReleasesSearchQuery = useScopedBrowseSearchQuery('newReleases');
|
|
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
|
|
newReleasesSearchQuery,
|
|
indexEnabled,
|
|
serverId,
|
|
);
|
|
const textSearchActive = textSearchAlbums != null;
|
|
const scopedSearchQuery = newReleasesSearchQuery.trim();
|
|
const albumBrowsePlainLayout =
|
|
perfFlags.disableMainstageVirtualLists
|
|
|| textSearchActive
|
|
|| scopedSearchQuery.length > 0;
|
|
|
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
|
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
|
|
const {
|
|
scrollBodyEl,
|
|
bindScrollBody: bindNewReleasesScrollBody,
|
|
getScrollRoot,
|
|
} = useInpageScrollViewport();
|
|
const {
|
|
loading,
|
|
setLoading,
|
|
resetPage,
|
|
runLoad,
|
|
requestNextPage,
|
|
isBlocked,
|
|
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null });
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
|
const genreFiltered = selectedGenres.length > 0;
|
|
|
|
const displayAlbums = useMemo(() => {
|
|
if (textSearchActive && textSearchAlbums) {
|
|
return genreFiltered
|
|
? filterAlbumsByGenres(textSearchAlbums, selectedGenres)
|
|
: textSearchAlbums;
|
|
}
|
|
return albums;
|
|
}, [textSearchActive, textSearchAlbums, albums, genreFiltered, selectedGenres]);
|
|
|
|
const loadingGrid = textSearchActive ? textSearchLoading : loading;
|
|
const gridHasMore = textSearchActive ? false : (!genreFiltered && hasMore);
|
|
|
|
gridSnapshotRef.current = { albums: displayAlbums, hasMore: gridHasMore };
|
|
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
|
|
|
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
|
newReleasesSearchQuery,
|
|
genreFiltered,
|
|
selectionMode,
|
|
selectedGenres,
|
|
]);
|
|
|
|
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 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 getAlbum(album.id);
|
|
downloadAlbum(album.id, album.name, album.artist, 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 (offset: number, append = false) => {
|
|
await runLoad(async () => {
|
|
const data = await getAlbumList('newest', PAGE_SIZE, offset);
|
|
if (append) setAlbums(prev => [...prev, ...data]);
|
|
else setAlbums(data);
|
|
setHasMore(data.length === PAGE_SIZE);
|
|
});
|
|
}, [runLoad]);
|
|
|
|
const loadFiltered = useCallback(async (genres: string[]) => {
|
|
setLoading(true);
|
|
try {
|
|
setAlbums(await fetchByGenres(genres));
|
|
setHasMore(false);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [musicLibraryFilterVersion]);
|
|
|
|
useEffect(() => {
|
|
if (restoringSessionRef.current || scopedSearchQuery) return;
|
|
if (genreFiltered) loadFiltered(selectedGenres);
|
|
else {
|
|
resetPage();
|
|
void load(0);
|
|
}
|
|
}, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery]);
|
|
|
|
const loadMore = useCallback(() => {
|
|
if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return;
|
|
requestNextPage(offset => load(offset, true));
|
|
}, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load]);
|
|
|
|
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
|
active: gridHasMore,
|
|
getScrollRoot,
|
|
scrollRootEl: scrollBodyEl,
|
|
onIntersect: loadMore,
|
|
});
|
|
|
|
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
|
serverId,
|
|
surface: 'new-releases',
|
|
scrollBodyEl,
|
|
displayAlbumsLength: displayAlbums.length,
|
|
loading: loadingGrid,
|
|
loadingMore: loadingGrid,
|
|
hasMore: gridHasMore,
|
|
loadMore,
|
|
});
|
|
|
|
useAlbumBrowseScrollReset({
|
|
scrollSnapshotRef,
|
|
getScrollRoot,
|
|
isScrollRestorePending,
|
|
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'),
|
|
});
|
|
|
|
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]);
|
|
|
|
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('sidebar.newReleases')}
|
|
</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}>
|
|
<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>
|
|
</>
|
|
) : (
|
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
|
)}
|
|
<button
|
|
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
|
|
onClick={toggleSelectionMode}
|
|
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
|
|
data-tooltip-pos="bottom"
|
|
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
|
|
>
|
|
<CheckSquare2 size={15} />
|
|
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<OverlayScrollArea
|
|
className="mainstage-inpage-scroll"
|
|
viewportClassName="mainstage-inpage-scroll__viewport"
|
|
viewportId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
|
viewportRef={bindNewReleasesScrollBody}
|
|
railInset="panel"
|
|
measureDeps={[
|
|
loadingGrid,
|
|
displayAlbums.length,
|
|
genreFiltered,
|
|
gridHasMore,
|
|
selectionMode,
|
|
newReleasesSearchQuery,
|
|
albumBrowsePlainLayout,
|
|
]}
|
|
>
|
|
{loadingGrid && displayAlbums.length === 0 ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
) : !loadingGrid && displayAlbums.length === 0 && !genreFiltered && !scopedSearchQuery ? (
|
|
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
|
{t('common.libraryEmpty')}
|
|
</div>
|
|
) : !loadingGrid && textSearchActive && displayAlbums.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' }}>
|
|
<VirtualCardGrid
|
|
items={displayAlbums}
|
|
itemKey={(a, _i) => a.id}
|
|
rowVariant="album"
|
|
disableVirtualization={albumBrowsePlainLayout}
|
|
layoutSignal={displayAlbums.length}
|
|
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
|
warmGridCovers={albumGridWarmCovers()}
|
|
renderItem={a => (
|
|
<AlbumCard
|
|
album={a}
|
|
observeScrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
|
selectionMode={selectionMode}
|
|
selected={selectedIds.has(a.id)}
|
|
onToggleSelect={toggleSelect}
|
|
selectedAlbums={selectedAlbums}
|
|
/>
|
|
)}
|
|
/>
|
|
{gridHasMore && (
|
|
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingGrid} />
|
|
)}
|
|
</div>
|
|
{isScrollRestorePending && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
paddingTop: '3rem',
|
|
background: 'var(--ctp-base)',
|
|
}}
|
|
>
|
|
<div className="spinner" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</OverlayScrollArea>
|
|
</div>
|
|
);
|
|
}
|