diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c35f2d..c6bbbded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## 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 **By [@Psychotoxical](https://github.com/Psychotoxical), suggested by volcs0, PR [#504](https://github.com/Psychotoxical/psysonic/pull/504)** diff --git a/src/App.tsx b/src/App.tsx index 3250a0c3..cda08a6e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ const RandomLanding = lazy(() => import('./pages/RandomLanding')); const Login = lazy(() => import('./pages/Login')); const AlbumDetail = lazy(() => import('./pages/AlbumDetail')); const MostPlayed = lazy(() => import('./pages/MostPlayed')); +const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums')); const RandomAlbums = lazy(() => import('./pages/RandomAlbums')); const LuckyMixPage = lazy(() => import('./pages/LuckyMix')); const SearchResults = lazy(() => import('./pages/SearchResults')); @@ -685,6 +686,7 @@ function AppShell() { } /> } /> } /> + } /> : } /> } /> } /> diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts index b8a0a73e..3d1a3a34 100644 --- a/src/api/navidromeBrowse.ts +++ b/src/api/navidromeBrowse.ts @@ -64,7 +64,7 @@ function mapNdSong(o: Record): 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. */ type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number }; @@ -258,6 +258,150 @@ export async function ndListAlbumsByArtistRole( return raw.map(a => mapNdAlbum(a as Record)); } +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; + /** 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 { + const PAGE_SIZE = req.songsPerPage ?? 200; + const MAX_PAGES = req.maxPagesPerCall ?? 5; + const targetAlbums = req.targetNewAlbums; + const seen = req.seenAlbumIds ?? new Set(); + 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 => { + const callOnce = async (token: string): Promise => + invoke('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; + 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. */ export function ndClearTokenCache(): void { cachedToken = null; diff --git a/src/components/LosslessAlbumsRail.tsx b/src/components/LosslessAlbumsRail.tsx new file mode 100644 index 00000000..3a586c1a --- /dev/null +++ b/src/components/LosslessAlbumsRail.tsx @@ -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([]); + + 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 ( + + ); +} diff --git a/src/config/navItems.ts b/src/config/navItems.ts index b3c396a0..3970c107 100644 --- a/src/config/navItems.ts +++ b/src/config/navItems.ts @@ -3,7 +3,7 @@ import { Disc3, Users, Music4, Radio, Heart, BarChart3, HelpCircle, Tags, ListMusic, Cast, TrendingUp, FolderOpen, HardDriveUpload, Wand2, Shuffle, Dices, Sparkles, - AudioLines, Feather, + AudioLines, Feather, Gem, } from 'lucide-react'; export interface NavItemMeta { @@ -29,6 +29,7 @@ export const ALL_NAV_ITEMS: Record = { favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' }, playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', 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' }, folderBrowser:{ icon: FolderOpen, labelKey: 'sidebar.folderBrowser',to: '/folders', section: 'library' }, deviceSync: { icon: HardDriveUpload,labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' }, diff --git a/src/locales/de.ts b/src/locales/de.ts index 00f386bd..38a2def7 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -25,6 +25,7 @@ export const deTranslation = { tracks: 'Titel', playlists: 'Playlists', mostPlayed: 'Meistgehört', + losslessAlbums: 'Lossless', radio: 'Internetradio', folderBrowser: 'Ordner-Browser', deviceSync: 'Gerätesync', @@ -41,6 +42,7 @@ export const deTranslation = { recent: 'Zuletzt hinzugefügt', mostPlayed: 'Meistgehört', recentlyPlayed: 'Kürzlich gespielt', + losslessAlbums: 'Lossless-Alben', discover: 'Entdecken', discoverSongs: 'Titel entdecken', loadMore: 'Mehr laden', @@ -1513,6 +1515,11 @@ export const deTranslation = { filterCompilations: 'Sampler-Künstler ausblenden (Various Artists, Soundtracks usw.)', 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: { title: 'Internetradio', empty: 'Keine Radiosender konfiguriert.', diff --git a/src/locales/en.ts b/src/locales/en.ts index de2933f1..85539d50 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -27,6 +27,7 @@ export const enTranslation = { playlists: 'Playlists', smartPlaylists: 'Smart Playlists', mostPlayed: 'Most Played', + losslessAlbums: 'Lossless', radio: 'Internet Radio', folderBrowser: 'Folder Browser', deviceSync: 'Device Sync', @@ -43,6 +44,7 @@ export const enTranslation = { recent: 'Recently Added', mostPlayed: 'Most Played', recentlyPlayed: 'Recently Played', + losslessAlbums: 'Lossless Albums', discover: 'Discover', discoverSongs: 'Discover Songs', loadMore: 'Load More', @@ -1519,6 +1521,11 @@ export const enTranslation = { filterCompilations: 'Hide compilation artists (Various Artists, Soundtracks, etc.)', 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: { title: 'Internet Radio', empty: 'No radio stations configured.', diff --git a/src/locales/es.ts b/src/locales/es.ts index a20f9bc5..6b70426c 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -26,6 +26,7 @@ export const esTranslation = { tracks: 'Canciones', playlists: 'Listas de Reproducción', mostPlayed: 'Más Reproducidos', + losslessAlbums: 'Sin Pérdidas', radio: 'Radio por Internet', folderBrowser: 'Explorar Carpetas', deviceSync: 'Sincronizar dispositivo', @@ -42,6 +43,7 @@ export const esTranslation = { recent: 'Agregados Recientemente', mostPlayed: 'Más Reproducidos', recentlyPlayed: 'Reproducidos Recientemente', + losslessAlbums: 'Álbumes sin Pérdidas', discover: 'Descubrir', discoverSongs: 'Descubrir canciones', loadMore: 'Cargar Más', @@ -1478,6 +1480,11 @@ export const esTranslation = { filterCompilations: 'Ocultar artistas de compilaciones (Various Artists, Soundtracks, etc.)', 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: { title: 'Radio por Internet', empty: 'No hay estaciones de radio configuradas.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 2ec4396b..c22fca92 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -25,6 +25,7 @@ export const frTranslation = { tracks: 'Titres', playlists: 'Playlists', mostPlayed: 'Les plus joués', + losslessAlbums: 'Lossless', radio: 'Radio Internet', folderBrowser: 'Explorateur de dossiers', deviceSync: 'Sync appareil', @@ -41,6 +42,7 @@ export const frTranslation = { recent: 'Ajoutés récemment', mostPlayed: 'Les plus écoutés', recentlyPlayed: 'Récemment écoutés', + losslessAlbums: 'Albums lossless', discover: 'Découvrir', discoverSongs: 'Découvrir des titres', loadMore: 'Charger plus', @@ -1473,6 +1475,11 @@ export const frTranslation = { filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)', 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: { title: 'Radio Internet', empty: 'Aucune station radio configurée.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 4e46515f..4e247261 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -25,6 +25,7 @@ export const nbTranslation = { tracks: 'Spor', playlists: 'Spillelister', mostPlayed: 'Mest spilt', + losslessAlbums: 'Lossless', radio: 'Internettradio', folderBrowser: 'Mappeleser', deviceSync: 'Enhetssynk', @@ -41,6 +42,7 @@ export const nbTranslation = { recent: 'Nylig lagt til', mostPlayed: 'Mest spilt', recentlyPlayed: 'Nylig spilt', + losslessAlbums: 'Lossless-album', discover: 'Oppdag', discoverSongs: 'Oppdag spor', loadMore: 'Last inn flere', @@ -1431,6 +1433,11 @@ export const nbTranslation = { filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)', 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: { sectionBasic: '1. Grunnleggende', sectionGenres: '2. Sjanger', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 9314a0d7..cd25392a 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -25,6 +25,7 @@ export const nlTranslation = { tracks: 'Nummers', playlists: 'Playlists', mostPlayed: 'Meest gespeeld', + losslessAlbums: 'Lossless', radio: 'Internetradio', folderBrowser: 'Mappenverkenner', deviceSync: 'Apparaatsync', @@ -41,6 +42,7 @@ export const nlTranslation = { recent: 'Recent toegevoegd', mostPlayed: 'Meest gespeeld', recentlyPlayed: 'Recent afgespeeld', + losslessAlbums: 'Lossless-albums', discover: 'Ontdekken', discoverSongs: 'Nummers ontdekken', loadMore: 'Meer laden', @@ -1472,6 +1474,11 @@ export const nlTranslation = { filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)', 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: { title: 'Internetradio', empty: 'Geen radiostations geconfigureerd.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index bc71d5c2..1f2507f9 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -27,6 +27,7 @@ export const ruTranslation = { playlists: 'Плейлисты', smartPlaylists: 'Смарт-плейлисты', mostPlayed: 'Популярное', + losslessAlbums: 'Lossless', radio: 'Онлайн-радио', folderBrowser: 'Браузер папок', deviceSync: 'Синхронизация устройства', @@ -43,6 +44,7 @@ export const ruTranslation = { recent: 'Недавно добавлено', mostPlayed: 'Популярное', recentlyPlayed: 'Недавно проиграно', + losslessAlbums: 'Lossless-альбомы', discover: 'Обзор', discoverSongs: 'Открыть треки', loadMore: 'Ещё', @@ -1536,6 +1538,11 @@ export const ruTranslation = { filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)', filterCompilationsShort: 'Скрыть сборники', }, + losslessAlbums: { + empty: 'В этой библиотеке пока нет lossless-альбомов.', + unsupported: 'Этот сервер не предоставляет метаданные, необходимые для поиска lossless-альбомов.', + slowFetchHint: 'Загружается медленнее других страниц альбомов — Psysonic проходит весь каталог песен по качеству.', + }, radio: { title: 'Онлайн-радио', empty: 'Радиостанции не настроены.', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 89b78eca..ffcdab2e 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -25,6 +25,7 @@ export const zhTranslation = { tracks: '曲目', playlists: '播放列表', mostPlayed: '最常播放', + losslessAlbums: '无损', radio: '网络电台', folderBrowser: '文件夹浏览器', deviceSync: '设备同步', @@ -41,6 +42,7 @@ export const zhTranslation = { recent: '最近添加', mostPlayed: '最常播放', recentlyPlayed: '最近播放', + losslessAlbums: '无损专辑', discover: '发现', discoverSongs: '发现曲目', loadMore: '加载更多', @@ -1465,6 +1467,11 @@ export const zhTranslation = { filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)', filterCompilationsShort: '隐藏合辑', }, + losslessAlbums: { + empty: '此媒体库中还没有无损专辑。', + unsupported: '此服务器未公开查找无损专辑所需的元数据。', + slowFetchHint: '加载比其他专辑页面慢 — Psysonic 按音质遍历整个歌曲目录。', + }, radio: { title: '网络电台', empty: '未配置任何电台。', diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ba52421f..451f09ac 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -3,6 +3,7 @@ import Hero from '../components/Hero'; import AlbumRow from '../components/AlbumRow'; import SongRail from '../components/SongRail'; import BecauseYouLikeRail from '../components/BecauseYouLikeRail'; +import LosslessAlbumsRail from '../components/LosslessAlbumsRail'; import { getAlbumList, getArtists, getRandomSongs, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; import { NavLink, useNavigate } from 'react-router-dom'; @@ -176,6 +177,11 @@ export default function Home() { isVisible('becauseYouLike') && becauseYouLikeHasSeed && reserveArtworkRow(); + const losslessAlbumsArtworkEnabled = + !homeRailArtworkDisabled && + !homeAlbumRowsDisabled && + isVisible('losslessAlbums') && + reserveArtworkRow(); const homeLiteArtworkFx = perfFlags.disableHomeArtworkFx; const homeFlatArtworkClip = perfFlags.disableHomeArtworkClip; @@ -292,6 +298,14 @@ export default function Home() { initialArtworkBudget={HOME_ALBUM_ROW_INITIAL_ARTWORK_BUDGET} /> )} + {!homeAlbumRowsDisabled && isVisible('losslessAlbums') && ( + + )} )} diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx new file mode 100644 index 00000000..bfb01626 --- /dev/null +++ b/src/pages/LosslessAlbums.tsx @@ -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([]); + 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>(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(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 ( +
+ {!perfFlags.disableMainstageStickyHeader && ( +
+
+

+ {selectionMode && selectedIds.size > 0 + ? t('albums.selectionCount', { count: selectedIds.size }) + : t('home.losslessAlbums')} +

+ {!(selectionMode && selectedIds.size > 0) && ( +

+ {t('losslessAlbums.slowFetchHint')} +

+ )} +
+
+ {selectionMode && selectedIds.size > 0 && ( + <> + + + + + )} + +
+
+ )} + + {unsupported ? ( +
+ {t('losslessAlbums.unsupported')} +
+ ) : loading && albums.length === 0 ? ( +
+
+
+ ) : albums.length === 0 ? ( +
+ {t('losslessAlbums.empty')} +
+ ) : ( + <> +
+ {albums.map(a => ( + + ))} +
+
+ {loading && hasMore &&
} +
+ + )} +
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c4e195b0..bb2078c6 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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)', '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)', + '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; @@ -4588,6 +4589,7 @@ function HomeCustomizer() { recentlyPlayed: t('home.recentlyPlayed'), starred: t('home.starred'), mostPlayed: t('home.mostPlayed'), + losslessAlbums: t('home.losslessAlbums'), }; return ( diff --git a/src/store/homeStore.ts b/src/store/homeStore.ts index 3af58872..fcc509d7 100644 --- a/src/store/homeStore.ts +++ b/src/store/homeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; 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 { id: HomeSectionId; @@ -18,6 +18,7 @@ export const DEFAULT_HOME_SECTIONS: HomeSectionConfig[] = [ { id: 'recentlyPlayed', visible: true }, { id: 'starred', visible: true }, { id: 'mostPlayed', visible: true }, + { id: 'losslessAlbums', visible: true }, ]; interface HomeStore { diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index b6ab00ae..272127b9 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -23,6 +23,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [ { id: 'favorites', visible: true }, { id: 'playlists', visible: true }, { id: 'mostPlayed', visible: true }, + { id: 'losslessAlbums',visible: true }, { id: 'radio', visible: true }, { id: 'folderBrowser', visible: false }, { id: 'deviceSync', visible: false },