mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page Rail under Home > mostPlayed and a dedicated infinite-scroll page that list albums whose tracks are tagged in lossless containers. Walks Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by albumId on the way down, stops when the song stream crosses into lossy (bitDepth==0) or the server runs out of rows. _filters has no operators on quality columns, so a sort + walk is the only path; equality on sample_rate / bit_depth probes returned empty (verified). The page paginates through the song-cursor with an in-flight ref so overlapping IntersectionObserver fires don't double-add albums, plus a cancelled flag so React StrictMode's double-mount doesn't apply two parallel result sets in dev. Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC *or* AAC and Navidrome's response carries an empty codec field, so we can't tell them apart; same story for wma. Allowlist is flac, wav, aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only lossless. ALAC-in-m4a setups will miss out, acceptable trade-off without a reliable codec field. Status: WIP. Settings home-customizer label and en.ts strings landed, no other locales yet, no quality badge on AlbumCards across the rest of the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the earlier hi-res-only iteration before the lossless broadening. * wip(home): Lossless page header parity + streaming + sidebar nav Page header now mirrors All Albums: selection mode with three action buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the same handlers Albums.tsx uses, selection-counter title swap, and the perfFlags.disableMainstageStickyHeader respect path. Filters were intentionally skipped — the rail is sorted by bit_depth, mixing client- side filters with server-driven pagination would produce gaps. Loading feels noticeably faster: ndListLosslessAlbumsPage takes an optional onProgress callback that fires once per internal fetch with the entries discovered in that fetch, so the page can stream new albums into state instead of waiting on the whole loadMore. Page-side budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst case vs 5 MB before), since the rail's catch-em-all pass is wrong for infinite-scroll UX. Subtitle under the page title primes the user that this is slower than other album pages because Psysonic walks the song catalog by quality (Navidrome ignores _fields, so per-song responses ship with lyrics + tags + participants whether we want them or not). Sidebar nav entry registered under 'losslessAlbums' with a Gem icon, defaults to visible:false (matches composers / folderBrowser / deviceSync — niche browsing modes). Existing users get the entry appended at the end of their persisted sidebar list automatically via the onRehydrateStorage merge that sidebarStore already runs for new DEFAULT_SIDEBAR_ITEMS. i18n: full coverage across all 8 locales for sidebar.losslessAlbums, home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported and the new losslessAlbums.slowFetchHint subtitle. ru/zh are machine-translation quality, flagged for a polish pass. * feat(home): default Lossless sidebar entry to visible Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false to true. Existing installs keep whatever the user has in persisted storage; fresh installs see the entry in the sidebar from the start. Earlier wip commit defaulted it off (matched composers / folderBrowser / deviceSync as a niche browse mode), but the rail + page do show something useful for any library with at least one FLAC/WAV/etc. album, so off-by-default just hid the feature. * docs: changelog entry for PR #506 Logs the Lossless Albums rail + page + sidebar entry in v1.46.0 "## Added". * docs(settings): contributor entry for PR #506 Adds the Lossless Albums rail/page bullet to Psychotoxical's contributions list.
This commit is contained in:
committed by
GitHub
parent
a41e3a624a
commit
bd56177e2c
@@ -18,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
|
### Lossless Albums — rail on Home + dedicated page + sidebar entry
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#506](https://github.com/Psychotoxical/psysonic/pull/506)**
|
||||||
|
|
||||||
|
* New **Lossless Albums** browse mode: a rail under "Most Played" on Home and a dedicated infinite-scroll **`/lossless-albums`** page with full Albums-page header parity (selection mode + Enqueue / Add Offline / Download ZIPs).
|
||||||
|
* Detection walks Navidrome's native `/api/song?_sort=bit_depth&_order=DESC` and dedupes to album ids along the way, stopping when the cursor crosses into lossy or runs out. Restricted to containers that are **always lossless** (`flac`, `wav`, `aiff`/`aif`, `dsf`/`dff`, `ape`, `wv`, `shn`, `tta`) — `m4a` and `wma` are intentionally excluded because they can carry both lossless and lossy codecs and Navidrome's `codec` field isn't reliable enough to disambiguate.
|
||||||
|
* Streaming load: albums stream into the page progressively as each internal fetch completes (`onProgress` callback) instead of blocking on the full `loadMore`.
|
||||||
|
* New sidebar entry **Lossless** (Gem icon), visible by default.
|
||||||
|
* i18n coverage across all 8 locales for the new strings (sidebar / home / page subtitle / empty / unsupported). RU and ZH are machine-translation quality, flagged for a polish pass.
|
||||||
|
|
||||||
### Song Info — absolute file path on Navidrome servers
|
### Song Info — absolute file path on Navidrome servers
|
||||||
|
|
||||||
**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by volcs0, PR [#504](https://github.com/Psychotoxical/psysonic/pull/504)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), suggested by volcs0, PR [#504](https://github.com/Psychotoxical/psysonic/pull/504)**
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const RandomLanding = lazy(() => import('./pages/RandomLanding'));
|
|||||||
const Login = lazy(() => import('./pages/Login'));
|
const Login = lazy(() => import('./pages/Login'));
|
||||||
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
|
const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
|
||||||
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
|
const MostPlayed = lazy(() => import('./pages/MostPlayed'));
|
||||||
|
const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums'));
|
||||||
const RandomAlbums = lazy(() => import('./pages/RandomAlbums'));
|
const RandomAlbums = lazy(() => import('./pages/RandomAlbums'));
|
||||||
const LuckyMixPage = lazy(() => import('./pages/LuckyMix'));
|
const LuckyMixPage = lazy(() => import('./pages/LuckyMix'));
|
||||||
const SearchResults = lazy(() => import('./pages/SearchResults'));
|
const SearchResults = lazy(() => import('./pages/SearchResults'));
|
||||||
@@ -685,6 +686,7 @@ function AppShell() {
|
|||||||
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
<Route path="/search/advanced" element={<AdvancedSearch />} />
|
||||||
<Route path="/statistics" element={<Statistics />} />
|
<Route path="/statistics" element={<Statistics />} />
|
||||||
<Route path="/most-played" element={<MostPlayed />} />
|
<Route path="/most-played" element={<MostPlayed />} />
|
||||||
|
<Route path="/lossless-albums" element={<LosslessAlbums />} />
|
||||||
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
<Route path="/now-playing" element={isMobile ? <MobilePlayerView /> : <NowPlayingPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/whats-new" element={<WhatsNew />} />
|
<Route path="/whats-new" element={<WhatsNew />} />
|
||||||
|
|||||||
+145
-1
@@ -64,7 +64,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating';
|
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating' | 'sample_rate' | 'bit_depth';
|
||||||
|
|
||||||
/** Optional opt-in cache for `ndListSongs` — keyed by call signature + active server. */
|
/** Optional opt-in cache for `ndListSongs` — keyed by call signature + active server. */
|
||||||
type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number };
|
type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number };
|
||||||
@@ -258,6 +258,150 @@ export async function ndListAlbumsByArtistRole(
|
|||||||
return raw.map(a => mapNdAlbum(a as Record<string, unknown>));
|
return raw.map(a => mapNdAlbum(a as Record<string, unknown>));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NdLosslessAlbumEntry {
|
||||||
|
album: SubsonicAlbum;
|
||||||
|
sampleRate: number;
|
||||||
|
bitDepth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NdLosslessPageRequest {
|
||||||
|
/** Resume the song-cursor from a previous call. Default 0 (start fresh). */
|
||||||
|
startSongOffset?: number;
|
||||||
|
songsPerPage?: number;
|
||||||
|
maxPagesPerCall?: number;
|
||||||
|
/** Stop once this many *new* unique album ids are collected this call. */
|
||||||
|
targetNewAlbums: number;
|
||||||
|
/** Mutated as the call walks; keep one Set across calls so repeated invocations
|
||||||
|
* return only albums you haven't seen yet. */
|
||||||
|
seenAlbumIds?: Set<string>;
|
||||||
|
/** Fires once per internal fetch with the entries discovered in that fetch.
|
||||||
|
* Lets a paginated UI render albums progressively while the rest of the
|
||||||
|
* call is still running — the song endpoint returns ~1 MB per 200-song
|
||||||
|
* fetch, so a single `loadMore` that internally pages 5× otherwise stalls
|
||||||
|
* the spinner for several seconds before any album appears. */
|
||||||
|
onProgress?: (entries: NdLosslessAlbumEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NdLosslessPage {
|
||||||
|
entries: NdLosslessAlbumEntry[];
|
||||||
|
/** True when the song stream entered lossy territory or the server ran
|
||||||
|
* out of rows — caller should stop paginating. */
|
||||||
|
done: boolean;
|
||||||
|
/** Pass back as `startSongOffset` on the next call to continue the walk. */
|
||||||
|
nextSongOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page of lossless albums. Walks the native API's `_sort=bit_depth`
|
||||||
|
* song stream (descending) so all 24/32-bit tracks come first, then 16-bit,
|
||||||
|
* then lossy formats which report `bit_depth: 0`. Dedupes to unique album
|
||||||
|
* ids on the way down and stops as soon as the stream crosses into lossy
|
||||||
|
* territory. `_filters` has no operators usable on quality columns so a
|
||||||
|
* sort + walk is the only path.
|
||||||
|
*
|
||||||
|
* Pages through the song stream internally up to `maxPagesPerCall` so albums
|
||||||
|
* with many tracks (compilations, big lossless box sets) don't soak up a
|
||||||
|
* single fetch window and starve the rest. Stops the internal pagination
|
||||||
|
* once `targetNewAlbums` unique ids are collected this call, the song stream
|
||||||
|
* crosses into lossy, the server returns a short page, or the per-call cap
|
||||||
|
* is hit.
|
||||||
|
*
|
||||||
|
* Stateful pagination (the dedicated Lossless page) reuses the returned
|
||||||
|
* `nextSongOffset` and a long-lived `seenAlbumIds` Set on subsequent calls.
|
||||||
|
* Single-shot pagination (the Home rail) just calls once and ignores the
|
||||||
|
* resume hooks. Returns empty page on Subsonic-only servers — caller treats
|
||||||
|
* that as a silent capability miss.
|
||||||
|
*/
|
||||||
|
/** File-extension allowlist of containers that are *only* lossless. Skips
|
||||||
|
* ambiguous wrappers (m4a/m4b — could be ALAC or AAC, codec field is often
|
||||||
|
* empty in Navidrome responses; wma — could be WMA Lossless or WMA Standard)
|
||||||
|
* because they require a codec check we can't reliably perform. */
|
||||||
|
const LOSSLESS_SUFFIXES = new Set(['flac', 'wav', 'wave', 'aiff', 'aif', 'dsf', 'dff', 'ape', 'wv', 'shn', 'tta']);
|
||||||
|
|
||||||
|
export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Promise<NdLosslessPage> {
|
||||||
|
const PAGE_SIZE = req.songsPerPage ?? 200;
|
||||||
|
const MAX_PAGES = req.maxPagesPerCall ?? 5;
|
||||||
|
const targetAlbums = req.targetNewAlbums;
|
||||||
|
const seen = req.seenAlbumIds ?? new Set<string>();
|
||||||
|
let songOffset = req.startSongOffset ?? 0;
|
||||||
|
|
||||||
|
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||||
|
if (!baseUrl) throw new Error('No server configured');
|
||||||
|
|
||||||
|
const fetchPage = async (start: number, end: number): Promise<unknown[]> => {
|
||||||
|
const callOnce = async (token: string): Promise<unknown> =>
|
||||||
|
invoke<unknown>('nd_list_songs', {
|
||||||
|
serverUrl: baseUrl,
|
||||||
|
token,
|
||||||
|
sort: 'bit_depth',
|
||||||
|
order: 'DESC',
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
let token = await getToken();
|
||||||
|
try {
|
||||||
|
const raw = await callOnce(token);
|
||||||
|
return Array.isArray(raw) ? raw : [];
|
||||||
|
} catch (err) {
|
||||||
|
const msg = String(err);
|
||||||
|
if (msg.includes('401') || msg.includes('403')) {
|
||||||
|
token = await getToken(true);
|
||||||
|
const raw = await callOnce(token);
|
||||||
|
return Array.isArray(raw) ? raw : [];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries: NdLosslessAlbumEntry[] = [];
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
for (let p = 0; p < MAX_PAGES; p++) {
|
||||||
|
const songs = await fetchPage(songOffset, songOffset + PAGE_SIZE);
|
||||||
|
if (songs.length === 0) { done = true; break; }
|
||||||
|
|
||||||
|
let belowThreshold = false;
|
||||||
|
const pageEntries: NdLosslessAlbumEntry[] = [];
|
||||||
|
for (const item of songs) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue;
|
||||||
|
const o = item as Record<string, unknown>;
|
||||||
|
const bitDepth = asNumber(o.bitDepth) ?? 0;
|
||||||
|
if (bitDepth <= 0) { belowThreshold = true; break; }
|
||||||
|
const suffix = (typeof o.suffix === 'string' ? o.suffix : '').toLowerCase();
|
||||||
|
if (!LOSSLESS_SUFFIXES.has(suffix)) continue;
|
||||||
|
const albumId = asString(o.albumId);
|
||||||
|
if (!albumId || seen.has(albumId)) continue;
|
||||||
|
seen.add(albumId);
|
||||||
|
|
||||||
|
const album: SubsonicAlbum = {
|
||||||
|
id: albumId,
|
||||||
|
name: asString(o.album),
|
||||||
|
artist: asString(o.albumArtist) || asString(o.artist),
|
||||||
|
artistId: asString(o.albumArtistId) || asString(o.artistId),
|
||||||
|
coverArt: asString(o.coverArtId) || albumId,
|
||||||
|
songCount: 0,
|
||||||
|
duration: 0,
|
||||||
|
year: asNumber(o.year),
|
||||||
|
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||||
|
};
|
||||||
|
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageEntries.length > 0) {
|
||||||
|
entries.push(...pageEntries);
|
||||||
|
req.onProgress?.(pageEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
songOffset += songs.length;
|
||||||
|
if (belowThreshold) { done = true; break; }
|
||||||
|
if (songs.length < PAGE_SIZE) { done = true; break; }
|
||||||
|
if (entries.length >= targetAlbums) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, done, nextSongOffset: songOffset };
|
||||||
|
}
|
||||||
|
|
||||||
/** Drop the cached token AND the songs cache — call when the active server changes. */
|
/** Drop the cached token AND the songs cache — call when the active server changes. */
|
||||||
export function ndClearTokenCache(): void {
|
export function ndClearTokenCache(): void {
|
||||||
cachedToken = null;
|
cachedToken = null;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
||||||
|
import AlbumRow from './AlbumRow';
|
||||||
|
import type { SubsonicAlbum } from '../api/subsonic';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
|
||||||
|
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 [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Disc3, Users, Music4, Radio, Heart, BarChart3,
|
Disc3, Users, Music4, Radio, Heart, BarChart3,
|
||||||
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
|
HelpCircle, Tags, ListMusic, Cast, TrendingUp,
|
||||||
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
|
FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles,
|
||||||
AudioLines, Feather,
|
AudioLines, Feather, Gem,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface NavItemMeta {
|
export interface NavItemMeta {
|
||||||
@@ -29,6 +29,7 @@ export const ALL_NAV_ITEMS: Record<string, NavItemMeta> = {
|
|||||||
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
|
||||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||||
|
losslessAlbums:{ icon: Gem, labelKey: 'sidebar.losslessAlbums',to: '/lossless-albums',section: 'library' },
|
||||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||||
folderBrowser:{ icon: FolderOpen, labelKey: 'sidebar.folderBrowser',to: '/folders', section: 'library' },
|
folderBrowser:{ icon: FolderOpen, labelKey: 'sidebar.folderBrowser',to: '/folders', section: 'library' },
|
||||||
deviceSync: { icon: HardDriveUpload,labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' },
|
deviceSync: { icon: HardDriveUpload,labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const deTranslation = {
|
|||||||
tracks: 'Titel',
|
tracks: 'Titel',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Meistgehört',
|
mostPlayed: 'Meistgehört',
|
||||||
|
losslessAlbums: 'Lossless',
|
||||||
radio: 'Internetradio',
|
radio: 'Internetradio',
|
||||||
folderBrowser: 'Ordner-Browser',
|
folderBrowser: 'Ordner-Browser',
|
||||||
deviceSync: 'Gerätesync',
|
deviceSync: 'Gerätesync',
|
||||||
@@ -41,6 +42,7 @@ export const deTranslation = {
|
|||||||
recent: 'Zuletzt hinzugefügt',
|
recent: 'Zuletzt hinzugefügt',
|
||||||
mostPlayed: 'Meistgehört',
|
mostPlayed: 'Meistgehört',
|
||||||
recentlyPlayed: 'Kürzlich gespielt',
|
recentlyPlayed: 'Kürzlich gespielt',
|
||||||
|
losslessAlbums: 'Lossless-Alben',
|
||||||
discover: 'Entdecken',
|
discover: 'Entdecken',
|
||||||
discoverSongs: 'Titel entdecken',
|
discoverSongs: 'Titel entdecken',
|
||||||
loadMore: 'Mehr laden',
|
loadMore: 'Mehr laden',
|
||||||
@@ -1513,6 +1515,11 @@ export const deTranslation = {
|
|||||||
filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)',
|
filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)',
|
||||||
filterCompilationsShort: 'Sampler ausblenden',
|
filterCompilationsShort: 'Sampler ausblenden',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'Noch keine Lossless-Alben in dieser Bibliothek.',
|
||||||
|
unsupported: 'Dieser Server liefert keine Metadaten, mit denen sich Lossless-Alben erkennen lassen.',
|
||||||
|
slowFetchHint: 'Lädt langsamer als andere Album-Seiten — Psysonic geht den gesamten Songkatalog nach Qualität durch.',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internetradio',
|
title: 'Internetradio',
|
||||||
empty: 'Keine Radiosender konfiguriert.',
|
empty: 'Keine Radiosender konfiguriert.',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const enTranslation = {
|
|||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
smartPlaylists: 'Smart Playlists',
|
smartPlaylists: 'Smart Playlists',
|
||||||
mostPlayed: 'Most Played',
|
mostPlayed: 'Most Played',
|
||||||
|
losslessAlbums: 'Lossless',
|
||||||
radio: 'Internet Radio',
|
radio: 'Internet Radio',
|
||||||
folderBrowser: 'Folder Browser',
|
folderBrowser: 'Folder Browser',
|
||||||
deviceSync: 'Device Sync',
|
deviceSync: 'Device Sync',
|
||||||
@@ -43,6 +44,7 @@ export const enTranslation = {
|
|||||||
recent: 'Recently Added',
|
recent: 'Recently Added',
|
||||||
mostPlayed: 'Most Played',
|
mostPlayed: 'Most Played',
|
||||||
recentlyPlayed: 'Recently Played',
|
recentlyPlayed: 'Recently Played',
|
||||||
|
losslessAlbums: 'Lossless Albums',
|
||||||
discover: 'Discover',
|
discover: 'Discover',
|
||||||
discoverSongs: 'Discover Songs',
|
discoverSongs: 'Discover Songs',
|
||||||
loadMore: 'Load More',
|
loadMore: 'Load More',
|
||||||
@@ -1519,6 +1521,11 @@ export const enTranslation = {
|
|||||||
filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)',
|
filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)',
|
||||||
filterCompilationsShort: 'Hide compilations',
|
filterCompilationsShort: 'Hide compilations',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'No lossless albums in this library yet.',
|
||||||
|
unsupported: 'This server does not expose the metadata needed to find lossless albums.',
|
||||||
|
slowFetchHint: 'Loads slower than other album pages — Psysonic walks the full song catalog by quality.',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internet Radio',
|
title: 'Internet Radio',
|
||||||
empty: 'No radio stations configured.',
|
empty: 'No radio stations configured.',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const esTranslation = {
|
|||||||
tracks: 'Canciones',
|
tracks: 'Canciones',
|
||||||
playlists: 'Listas de Reproducción',
|
playlists: 'Listas de Reproducción',
|
||||||
mostPlayed: 'Más Reproducidos',
|
mostPlayed: 'Más Reproducidos',
|
||||||
|
losslessAlbums: 'Sin Pérdidas',
|
||||||
radio: 'Radio por Internet',
|
radio: 'Radio por Internet',
|
||||||
folderBrowser: 'Explorar Carpetas',
|
folderBrowser: 'Explorar Carpetas',
|
||||||
deviceSync: 'Sincronizar dispositivo',
|
deviceSync: 'Sincronizar dispositivo',
|
||||||
@@ -42,6 +43,7 @@ export const esTranslation = {
|
|||||||
recent: 'Agregados Recientemente',
|
recent: 'Agregados Recientemente',
|
||||||
mostPlayed: 'Más Reproducidos',
|
mostPlayed: 'Más Reproducidos',
|
||||||
recentlyPlayed: 'Reproducidos Recientemente',
|
recentlyPlayed: 'Reproducidos Recientemente',
|
||||||
|
losslessAlbums: 'Álbumes sin Pérdidas',
|
||||||
discover: 'Descubrir',
|
discover: 'Descubrir',
|
||||||
discoverSongs: 'Descubrir canciones',
|
discoverSongs: 'Descubrir canciones',
|
||||||
loadMore: 'Cargar Más',
|
loadMore: 'Cargar Más',
|
||||||
@@ -1478,6 +1480,11 @@ export const esTranslation = {
|
|||||||
filterCompilations: 'Ocultar artistas de compilaciones (Various Artists, Soundtracks, etc.)',
|
filterCompilations: 'Ocultar artistas de compilaciones (Various Artists, Soundtracks, etc.)',
|
||||||
filterCompilationsShort: 'Ocultar compilaciones',
|
filterCompilationsShort: 'Ocultar compilaciones',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'Aún no hay álbumes sin pérdidas en esta biblioteca.',
|
||||||
|
unsupported: 'Este servidor no expone los metadatos necesarios para encontrar álbumes sin pérdidas.',
|
||||||
|
slowFetchHint: 'Carga más lento que otras páginas de álbumes — Psysonic recorre todo el catálogo de canciones por calidad.',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Radio por Internet',
|
title: 'Radio por Internet',
|
||||||
empty: 'No hay estaciones de radio configuradas.',
|
empty: 'No hay estaciones de radio configuradas.',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const frTranslation = {
|
|||||||
tracks: 'Titres',
|
tracks: 'Titres',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Les plus joués',
|
mostPlayed: 'Les plus joués',
|
||||||
|
losslessAlbums: 'Lossless',
|
||||||
radio: 'Radio Internet',
|
radio: 'Radio Internet',
|
||||||
folderBrowser: 'Explorateur de dossiers',
|
folderBrowser: 'Explorateur de dossiers',
|
||||||
deviceSync: 'Sync appareil',
|
deviceSync: 'Sync appareil',
|
||||||
@@ -41,6 +42,7 @@ export const frTranslation = {
|
|||||||
recent: 'Ajoutés récemment',
|
recent: 'Ajoutés récemment',
|
||||||
mostPlayed: 'Les plus écoutés',
|
mostPlayed: 'Les plus écoutés',
|
||||||
recentlyPlayed: 'Récemment écoutés',
|
recentlyPlayed: 'Récemment écoutés',
|
||||||
|
losslessAlbums: 'Albums lossless',
|
||||||
discover: 'Découvrir',
|
discover: 'Découvrir',
|
||||||
discoverSongs: 'Découvrir des titres',
|
discoverSongs: 'Découvrir des titres',
|
||||||
loadMore: 'Charger plus',
|
loadMore: 'Charger plus',
|
||||||
@@ -1473,6 +1475,11 @@ export const frTranslation = {
|
|||||||
filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)',
|
filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)',
|
||||||
filterCompilationsShort: 'Masquer les compilations',
|
filterCompilationsShort: 'Masquer les compilations',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'Aucun album lossless dans cette bibliothèque pour l\'instant.',
|
||||||
|
unsupported: 'Ce serveur n\'expose pas les métadonnées nécessaires pour trouver des albums lossless.',
|
||||||
|
slowFetchHint: 'Chargement plus lent que les autres pages d\'albums — Psysonic parcourt tout le catalogue par qualité.',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Radio Internet',
|
title: 'Radio Internet',
|
||||||
empty: 'Aucune station radio configurée.',
|
empty: 'Aucune station radio configurée.',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const nbTranslation = {
|
|||||||
tracks: 'Spor',
|
tracks: 'Spor',
|
||||||
playlists: 'Spillelister',
|
playlists: 'Spillelister',
|
||||||
mostPlayed: 'Mest spilt',
|
mostPlayed: 'Mest spilt',
|
||||||
|
losslessAlbums: 'Lossless',
|
||||||
radio: 'Internettradio',
|
radio: 'Internettradio',
|
||||||
folderBrowser: 'Mappeleser',
|
folderBrowser: 'Mappeleser',
|
||||||
deviceSync: 'Enhetssynk',
|
deviceSync: 'Enhetssynk',
|
||||||
@@ -41,6 +42,7 @@ export const nbTranslation = {
|
|||||||
recent: 'Nylig lagt til',
|
recent: 'Nylig lagt til',
|
||||||
mostPlayed: 'Mest spilt',
|
mostPlayed: 'Mest spilt',
|
||||||
recentlyPlayed: 'Nylig spilt',
|
recentlyPlayed: 'Nylig spilt',
|
||||||
|
losslessAlbums: 'Lossless-album',
|
||||||
discover: 'Oppdag',
|
discover: 'Oppdag',
|
||||||
discoverSongs: 'Oppdag spor',
|
discoverSongs: 'Oppdag spor',
|
||||||
loadMore: 'Last inn flere',
|
loadMore: 'Last inn flere',
|
||||||
@@ -1431,6 +1433,11 @@ export const nbTranslation = {
|
|||||||
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
|
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
|
||||||
filterCompilationsShort: 'Skjul kompilasjoner',
|
filterCompilationsShort: 'Skjul kompilasjoner',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'Ingen lossless-album i dette biblioteket ennå.',
|
||||||
|
unsupported: 'Denne serveren eksponerer ikke metadata som trengs for å finne lossless-album.',
|
||||||
|
slowFetchHint: 'Lastes saktere enn andre albumsider — Psysonic går gjennom hele sangkatalogen etter kvalitet.',
|
||||||
|
},
|
||||||
smartPlaylists: {
|
smartPlaylists: {
|
||||||
sectionBasic: '1. Grunnleggende',
|
sectionBasic: '1. Grunnleggende',
|
||||||
sectionGenres: '2. Sjanger',
|
sectionGenres: '2. Sjanger',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const nlTranslation = {
|
|||||||
tracks: 'Nummers',
|
tracks: 'Nummers',
|
||||||
playlists: 'Playlists',
|
playlists: 'Playlists',
|
||||||
mostPlayed: 'Meest gespeeld',
|
mostPlayed: 'Meest gespeeld',
|
||||||
|
losslessAlbums: 'Lossless',
|
||||||
radio: 'Internetradio',
|
radio: 'Internetradio',
|
||||||
folderBrowser: 'Mappenverkenner',
|
folderBrowser: 'Mappenverkenner',
|
||||||
deviceSync: 'Apparaatsync',
|
deviceSync: 'Apparaatsync',
|
||||||
@@ -41,6 +42,7 @@ export const nlTranslation = {
|
|||||||
recent: 'Recent toegevoegd',
|
recent: 'Recent toegevoegd',
|
||||||
mostPlayed: 'Meest gespeeld',
|
mostPlayed: 'Meest gespeeld',
|
||||||
recentlyPlayed: 'Recent afgespeeld',
|
recentlyPlayed: 'Recent afgespeeld',
|
||||||
|
losslessAlbums: 'Lossless-albums',
|
||||||
discover: 'Ontdekken',
|
discover: 'Ontdekken',
|
||||||
discoverSongs: 'Nummers ontdekken',
|
discoverSongs: 'Nummers ontdekken',
|
||||||
loadMore: 'Meer laden',
|
loadMore: 'Meer laden',
|
||||||
@@ -1472,6 +1474,11 @@ export const nlTranslation = {
|
|||||||
filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)',
|
filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)',
|
||||||
filterCompilationsShort: 'Compilaties verbergen',
|
filterCompilationsShort: 'Compilaties verbergen',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'Nog geen lossless-albums in deze bibliotheek.',
|
||||||
|
unsupported: 'Deze server biedt geen metadata om lossless-albums te vinden.',
|
||||||
|
slowFetchHint: 'Laadt langzamer dan andere albumpagina\'s — Psysonic doorloopt de volledige songcatalogus op kwaliteit.',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internetradio',
|
title: 'Internetradio',
|
||||||
empty: 'Geen radiostations geconfigureerd.',
|
empty: 'Geen radiostations geconfigureerd.',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const ruTranslation = {
|
|||||||
playlists: 'Плейлисты',
|
playlists: 'Плейлисты',
|
||||||
smartPlaylists: 'Смарт-плейлисты',
|
smartPlaylists: 'Смарт-плейлисты',
|
||||||
mostPlayed: 'Популярное',
|
mostPlayed: 'Популярное',
|
||||||
|
losslessAlbums: 'Lossless',
|
||||||
radio: 'Онлайн-радио',
|
radio: 'Онлайн-радио',
|
||||||
folderBrowser: 'Браузер папок',
|
folderBrowser: 'Браузер папок',
|
||||||
deviceSync: 'Синхронизация устройства',
|
deviceSync: 'Синхронизация устройства',
|
||||||
@@ -43,6 +44,7 @@ export const ruTranslation = {
|
|||||||
recent: 'Недавно добавлено',
|
recent: 'Недавно добавлено',
|
||||||
mostPlayed: 'Популярное',
|
mostPlayed: 'Популярное',
|
||||||
recentlyPlayed: 'Недавно проиграно',
|
recentlyPlayed: 'Недавно проиграно',
|
||||||
|
losslessAlbums: 'Lossless-альбомы',
|
||||||
discover: 'Обзор',
|
discover: 'Обзор',
|
||||||
discoverSongs: 'Открыть треки',
|
discoverSongs: 'Открыть треки',
|
||||||
loadMore: 'Ещё',
|
loadMore: 'Ещё',
|
||||||
@@ -1536,6 +1538,11 @@ export const ruTranslation = {
|
|||||||
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
|
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
|
||||||
filterCompilationsShort: 'Скрыть сборники',
|
filterCompilationsShort: 'Скрыть сборники',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: 'В этой библиотеке пока нет lossless-альбомов.',
|
||||||
|
unsupported: 'Этот сервер не предоставляет метаданные, необходимые для поиска lossless-альбомов.',
|
||||||
|
slowFetchHint: 'Загружается медленнее других страниц альбомов — Psysonic проходит весь каталог песен по качеству.',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Онлайн-радио',
|
title: 'Онлайн-радио',
|
||||||
empty: 'Радиостанции не настроены.',
|
empty: 'Радиостанции не настроены.',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const zhTranslation = {
|
|||||||
tracks: '曲目',
|
tracks: '曲目',
|
||||||
playlists: '播放列表',
|
playlists: '播放列表',
|
||||||
mostPlayed: '最常播放',
|
mostPlayed: '最常播放',
|
||||||
|
losslessAlbums: '无损',
|
||||||
radio: '网络电台',
|
radio: '网络电台',
|
||||||
folderBrowser: '文件夹浏览器',
|
folderBrowser: '文件夹浏览器',
|
||||||
deviceSync: '设备同步',
|
deviceSync: '设备同步',
|
||||||
@@ -41,6 +42,7 @@ export const zhTranslation = {
|
|||||||
recent: '最近添加',
|
recent: '最近添加',
|
||||||
mostPlayed: '最常播放',
|
mostPlayed: '最常播放',
|
||||||
recentlyPlayed: '最近播放',
|
recentlyPlayed: '最近播放',
|
||||||
|
losslessAlbums: '无损专辑',
|
||||||
discover: '发现',
|
discover: '发现',
|
||||||
discoverSongs: '发现曲目',
|
discoverSongs: '发现曲目',
|
||||||
loadMore: '加载更多',
|
loadMore: '加载更多',
|
||||||
@@ -1465,6 +1467,11 @@ export const zhTranslation = {
|
|||||||
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
|
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
|
||||||
filterCompilationsShort: '隐藏合辑',
|
filterCompilationsShort: '隐藏合辑',
|
||||||
},
|
},
|
||||||
|
losslessAlbums: {
|
||||||
|
empty: '此媒体库中还没有无损专辑。',
|
||||||
|
unsupported: '此服务器未公开查找无损专辑所需的元数据。',
|
||||||
|
slowFetchHint: '加载比其他专辑页面慢 — Psysonic 按音质遍历整个歌曲目录。',
|
||||||
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: '网络电台',
|
title: '网络电台',
|
||||||
empty: '未配置任何电台。',
|
empty: '未配置任何电台。',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Hero from '../components/Hero';
|
|||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import SongRail from '../components/SongRail';
|
import SongRail from '../components/SongRail';
|
||||||
import BecauseYouLikeRail from '../components/BecauseYouLikeRail';
|
import BecauseYouLikeRail from '../components/BecauseYouLikeRail';
|
||||||
|
import LosslessAlbumsRail from '../components/LosslessAlbumsRail';
|
||||||
import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { NavLink, useNavigate } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
@@ -176,6 +177,11 @@ export default function Home() {
|
|||||||
isVisible('becauseYouLike') &&
|
isVisible('becauseYouLike') &&
|
||||||
becauseYouLikeHasSeed &&
|
becauseYouLikeHasSeed &&
|
||||||
reserveArtworkRow();
|
reserveArtworkRow();
|
||||||
|
const losslessAlbumsArtworkEnabled =
|
||||||
|
!homeRailArtworkDisabled &&
|
||||||
|
!homeAlbumRowsDisabled &&
|
||||||
|
isVisible('losslessAlbums') &&
|
||||||
|
reserveArtworkRow();
|
||||||
|
|
||||||
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
|
const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx;
|
||||||
const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip;
|
const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip;
|
||||||
@@ -292,6 +298,14 @@ export default function Home() {
|
|||||||
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
|
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!homeAlbumRowsDisabled && isVisible('losslessAlbums') && (
|
||||||
|
<LosslessAlbumsRail
|
||||||
|
disableArtwork={!losslessAlbumsArtworkEnabled}
|
||||||
|
artworkSize={HOME_ALBUM_ROW_ARTWORK_SIZE}
|
||||||
|
windowArtworkByViewport={HOME_ARTWORK_WINDOWING}
|
||||||
|
initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import AlbumCard from '../components/AlbumCard';
|
||||||
|
import { ndListLosslessAlbumsPage } from '../api/navidromeBrowse';
|
||||||
|
import { getAlbum, type SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
|
import { useDownloadModalStore } from '../store/downloadModalStore';
|
||||||
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
|
import { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
|
import { useRangeSelection } from '../hooks/useRangeSelection';
|
||||||
|
import { usePerfProbeFlags } from '../utils/perfFlags';
|
||||||
|
import { showToast } from '../utils/toast';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { join } from '@tauri-apps/api/path';
|
||||||
|
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
|
||||||
|
|
||||||
|
/** Per-loadMore budget — tuned for snappy initial paint over completeness.
|
||||||
|
* 100 songs ≈ 500 KB response (Navidrome's /api/song carries lyrics/tags/
|
||||||
|
* participants and ignores `_fields`); 2 internal pages = ~1 MB worst case
|
||||||
|
* per loadMore, much faster than the rail's 5×200 = 1000-song budget. The
|
||||||
|
* page makes up for the smaller batch by triggering a fresh loadMore on
|
||||||
|
* scroll, so the user sees albums sooner instead of waiting on a fat call. */
|
||||||
|
const PAGE_TARGET_ALBUMS = 12;
|
||||||
|
const PAGE_SONGS_PER_FETCH = 100;
|
||||||
|
const PAGE_MAX_FETCHES_PER_LOAD = 2;
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
||||||
|
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
||||||
|
|
||||||
|
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
||||||
|
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
||||||
|
|
||||||
|
/** Pagination cursor + dedupe set, kept across loadMore calls so each page
|
||||||
|
* resumes the song-stream walk where the previous one left off. Reset to
|
||||||
|
* a fresh pair whenever the active server changes. */
|
||||||
|
const songCursor = useRef(0);
|
||||||
|
const seenIds = useRef<Set<string>>(new Set());
|
||||||
|
/** Re-entrancy guard. The IntersectionObserver can fire repeatedly while a
|
||||||
|
* previous loadMore is still in flight (fast scroll, sentinel re-entering
|
||||||
|
* the rootMargin band) — without this guard, two concurrent calls would
|
||||||
|
* read the same songCursor, fetch the same song page, and push duplicate
|
||||||
|
* album entries because each captures its own snapshot of the seen-Set
|
||||||
|
* reference. */
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const page = await ndListLosslessAlbumsPage({
|
||||||
|
startSongOffset: songCursor.current,
|
||||||
|
seenAlbumIds: seenIds.current,
|
||||||
|
targetNewAlbums: PAGE_TARGET_ALBUMS,
|
||||||
|
songsPerPage: PAGE_SONGS_PER_FETCH,
|
||||||
|
maxPagesPerCall: PAGE_MAX_FETCHES_PER_LOAD,
|
||||||
|
onProgress: (newEntries) => {
|
||||||
|
setAlbums(prev => [...prev, ...newEntries.map(e => e.album)]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
songCursor.current = page.nextSongOffset;
|
||||||
|
setHasMore(!page.done);
|
||||||
|
} catch {
|
||||||
|
setUnsupported(true);
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
songCursor.current = 0;
|
||||||
|
seenIds.current = new Set();
|
||||||
|
inFlight.current = false;
|
||||||
|
setAlbums([]);
|
||||||
|
setHasMore(true);
|
||||||
|
setUnsupported(false);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
inFlight.current = true;
|
||||||
|
try {
|
||||||
|
const page = await ndListLosslessAlbumsPage({
|
||||||
|
startSongOffset: 0,
|
||||||
|
seenAlbumIds: seenIds.current,
|
||||||
|
targetNewAlbums: PAGE_TARGET_ALBUMS,
|
||||||
|
songsPerPage: PAGE_SONGS_PER_FETCH,
|
||||||
|
maxPagesPerCall: PAGE_MAX_FETCHES_PER_LOAD,
|
||||||
|
onProgress: (newEntries) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAlbums(prev => [...prev, ...newEntries.map(e => e.album)]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
songCursor.current = page.nextSongOffset;
|
||||||
|
setHasMore(!page.done);
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return;
|
||||||
|
setUnsupported(true);
|
||||||
|
setHasMore(false);
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [activeServerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMore) return;
|
||||||
|
const node = observerTarget.current;
|
||||||
|
if (!node) return;
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
entries => { if (entries[0].isIntersecting) loadMore(); },
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
obs.observe(node);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, [hasMore, loadMore, loading, albums.length]);
|
||||||
|
|
||||||
|
const handleEnqueueSelected = async () => {
|
||||||
|
if (selectedAlbums.length === 0) return;
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(selectedAlbums.map(a => getAlbum(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 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 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">
|
||||||
|
{!perfFlags.disableMainstageStickyHeader && (
|
||||||
|
<div className="page-sticky-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
|
<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) && (
|
||||||
|
<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 && (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unsupported ? (
|
||||||
|
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('losslessAlbums.unsupported')}
|
||||||
|
</div>
|
||||||
|
) : loading && albums.length === 0 ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
) : albums.length === 0 ? (
|
||||||
|
<div style={{ padding: '3rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
|
{t('losslessAlbums.empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="album-grid-wrap">
|
||||||
|
{albums.map(a => (
|
||||||
|
<AlbumCard
|
||||||
|
key={a.id}
|
||||||
|
album={a}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
selected={selectedIds.has(a.id)}
|
||||||
|
onToggleSelect={toggleSelect}
|
||||||
|
selectedAlbums={selectedAlbums}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -372,6 +372,7 @@ const CONTRIBUTORS = [
|
|||||||
'Library: Browse by Composer — native-API role listing for classical libraries, library-scoped queries, composer as a first-class share entity (PR #487)',
|
'Library: Browse by Composer — native-API role listing for classical libraries, library-scoped queries, composer as a first-class share entity (PR #487)',
|
||||||
'Home: "Because you listened" recommendation rail — Last.fm-anchored similar-artist surfacing with round-robin anchor rotation per server (PR #489)',
|
'Home: "Because you listened" recommendation rail — Last.fm-anchored similar-artist surfacing with round-robin anchor rotation per server (PR #489)',
|
||||||
'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)',
|
'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)',
|
||||||
|
'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
@@ -4588,6 +4589,7 @@ function HomeCustomizer() {
|
|||||||
recentlyPlayed: t('home.recentlyPlayed'),
|
recentlyPlayed: t('home.recentlyPlayed'),
|
||||||
starred: t('home.starred'),
|
starred: t('home.starred'),
|
||||||
mostPlayed: t('home.mostPlayed'),
|
mostPlayed: t('home.mostPlayed'),
|
||||||
|
losslessAlbums: t('home.losslessAlbums'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'becauseYouLike' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed';
|
export type HomeSectionId = 'hero' | 'recent' | 'discover' | 'becauseYouLike' | 'discoverSongs' | 'discoverArtists' | 'recentlyPlayed' | 'starred' | 'mostPlayed' | 'losslessAlbums';
|
||||||
|
|
||||||
export interface HomeSectionConfig {
|
export interface HomeSectionConfig {
|
||||||
id: HomeSectionId;
|
id: HomeSectionId;
|
||||||
@@ -18,6 +18,7 @@ export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [
|
|||||||
{ id: 'recentlyPlayed', visible: true },
|
{ id: 'recentlyPlayed', visible: true },
|
||||||
{ id: 'starred', visible: true },
|
{ id: 'starred', visible: true },
|
||||||
{ id: 'mostPlayed', visible: true },
|
{ id: 'mostPlayed', visible: true },
|
||||||
|
{ id: 'losslessAlbums', visible: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HomeStore {
|
interface HomeStore {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
|||||||
{ id: 'favorites', visible: true },
|
{ id: 'favorites', visible: true },
|
||||||
{ id: 'playlists', visible: true },
|
{ id: 'playlists', visible: true },
|
||||||
{ id: 'mostPlayed', visible: true },
|
{ id: 'mostPlayed', visible: true },
|
||||||
|
{ id: 'losslessAlbums',visible: true },
|
||||||
{ id: 'radio', visible: true },
|
{ id: 'radio', visible: true },
|
||||||
{ id: 'folderBrowser', visible: false },
|
{ id: 'folderBrowser', visible: false },
|
||||||
{ id: 'deviceSync', visible: false },
|
{ id: 'deviceSync', visible: false },
|
||||||
|
|||||||
Reference in New Issue
Block a user